Mise en place de la feature de gestion des Frais
This commit is contained in:
1 parent
9ea078302d
commit
0334a63139
27 files changed
+2401
-117
No files matched your search
@@ -264,6 +264,7 @@ export default function Aide() {
|
||||
+ Remboursements crédités au porte-monnaie<br />
|
||||
+ Bonus (parrainage / plateforme)<br />
|
||||
− Capital investi (en cours ou remboursé)<br />
|
||||
− Frais (déduits à la souscription ou réglés via le porte-monnaie)<br />
|
||||
+ Corrections de solde
|
||||
</div>
|
||||
|
||||
@@ -297,10 +298,61 @@ export default function Aide() {
|
||||
(y compris les réinvestissements complémentaires) est soustrait du porte-monnaie, car ces fonds ne sont
|
||||
plus disponibles. Ils reviennent progressivement via les remboursements.</p>
|
||||
|
||||
<p><strong style={{ color: 'var(--text)' }}>Frais</strong> — les frais liés aux investissements (voir la question dédiée
|
||||
ci-dessous) réduisent le solde lorsqu'ils sont réglés « à la source » (déduits du versement lors de la
|
||||
souscription) ou via le porte-monnaie. Les frais réglés depuis un compte courant du détenteur, ou retenus
|
||||
directement sur un remboursement, n'ont aucun impact ici — ils ne transitent jamais par le porte-monnaie de
|
||||
la plateforme.</p>
|
||||
|
||||
<p style={{ marginBottom: 0 }}><strong style={{ color: 'var(--text)' }}>Corrections de solde</strong> — ajustements manuels
|
||||
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Comment fonctionne le suivi des frais liés aux investissements ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Certaines plateformes facturent des frais en dehors des intérêts et prélèvements fiscaux habituels : frais
|
||||
d'entrée à la souscription, frais de gestion, frais sur une distribution (retenus ou non sur un
|
||||
remboursement), ou autre. Le bloc <em>Frais d'opération</em> de la fiche d'un investissement les recense tous.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Où saisir un frais ?</h4>
|
||||
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Frais de souscription</strong> — depuis l'étape « Frais » du
|
||||
formulaire de création ou modification de l'investissement. Un seul frais de cette catégorie par
|
||||
investissement.</li>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Frais de gestion ou autre</strong> — depuis le bouton
|
||||
<strong style={{ color: 'var(--text)' }}> + Ajouter</strong> du bloc <em>Frais d'opération</em> sur la fiche.</li>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Frais sur distribution</strong> — depuis le champ
|
||||
<strong style={{ color: 'var(--text)' }}> Frais</strong> du formulaire d'un remboursement. La ligne est créée et
|
||||
synchronisée automatiquement dans le bloc <em>Frais d'opération</em> ; elle ne peut être modifiée ou
|
||||
supprimée que depuis ce remboursement, pour éviter toute désynchronisation.</li>
|
||||
</ul>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Mode de règlement et impact</h4>
|
||||
<p>Chaque frais est réglé de l'une des quatre façons suivantes, avec un impact différent :</p>
|
||||
<ul style={{ margin: '8px 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Déduit du versement lors de la souscription</strong> — aucune
|
||||
ligne de mouvement séparée : le montant est simplement documenté à titre de traçabilité, mais il réduit
|
||||
le solde du porte-monnaie (voir la question ci-dessus).</li>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Porte-monnaie</strong> — débité du solde disponible sur la
|
||||
plateforme, comme un retrait.</li>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Débité du compte courant</strong> — réglé depuis un compte
|
||||
courant du détenteur (choisi à la saisie), sans aucun impact sur le porte-monnaie de la plateforme.</li>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Déduit du remboursement</strong> — retenu directement sur le
|
||||
remboursement lié : le montant réduit alors la base imposable et les <em>intérêts nets</em>/<em>net reçu</em>
|
||||
de ce remboursement (comportement historique). C'est le seul mode où le frais n'est pas une sortie d'argent
|
||||
séparée à suivre par ailleurs.</li>
|
||||
</ul>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
|
||||
@@ -621,7 +673,7 @@ export default function Aide() {
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_dashboard</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits — filtrable par année.</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits, gain net depuis le début (frais hors remboursement déduits) — filtrable par année.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
@@ -633,13 +685,19 @@ export default function Aide() {
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investissement</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris la liste de ses remboursements réels perçus.</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris ses remboursements réels perçus et les frais qui lui sont liés (souscription, gestion, distribution).</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_remboursements</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), filtrable par période.</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), avec le frais éventuellement retenu sur chacun, filtrable par période.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_frais_operations</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Frais liés aux investissements — souscription, gestion, distribution — toutes plateformes, filtrable par période.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
@@ -684,6 +742,7 @@ export default function Aide() {
|
||||
<li>« Donne-moi le détail de l'investissement 42, avec ses remboursements. »</li>
|
||||
<li>« Quels remboursements ai-je reçus entre le 1er et le 30 juin 2026 ? »</li>
|
||||
<li>« Quels ont été mes derniers dépôts et retraits ? »</li>
|
||||
<li>« Combien ai-je payé de frais cette année, et sur quels investissements ? »</li>
|
||||
<li>« Regarde cette annonce de projet et propose-moi les infos pour créer l'investissement : [URL] »
|
||||
(développement local, avec <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> activé)</li>
|
||||
<li>« Ai-je atteint mon objectif de versement pour {new Date().getFullYear()} ? »</li>
|
||||
@@ -691,7 +750,7 @@ export default function Aide() {
|
||||
</ul>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Ces exemples fonctionnent aussi bien en dev qu'en prod dès lors que le serveur correspondant est connecté
|
||||
(voir les FAQ de configuration ci-dessus) — les huit premiers outils sont identiques dans les deux environnements.
|
||||
(voir les FAQ de configuration ci-dessus) — les neuf premiers outils sont identiques dans les deux environnements.
|
||||
</p>
|
||||
</FaqItem>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -124,6 +124,7 @@ export default function DepotsRetraits() {
|
||||
const [allRemb, setAllRemb] = useState([]);
|
||||
const [allInv, setAllInv] = useState([]);
|
||||
const [allReinvests, setAllReinvests] = useState([]);
|
||||
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via invById, cf. reinvestissements)
|
||||
|
||||
const [allCorrections, setAllCorrections] = useState([]);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
@@ -176,7 +177,7 @@ export default function DepotsRetraits() {
|
||||
setDashData(null);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
|
||||
try {
|
||||
const [r, p, d, remb, inv, corr, reinvests] = await Promise.all([
|
||||
const [r, p, d, remb, inv, corr, reinvests, fraisOps] = await Promise.all([
|
||||
api.get('/depots-retraits', scopeParams),
|
||||
api.get('/plateformes'),
|
||||
api.get('/dashboard', scopeParams),
|
||||
@@ -186,9 +187,13 @@ export default function DepotsRetraits() {
|
||||
// Toujours scope 'all' — filtré côté client via allInv (déjà scopé) pour respecter
|
||||
// le même pattern que Plateformes.jsx (route /reinvestissements ne connaît que l'utilisateur).
|
||||
api.get('/reinvestissements', { scope: 'all' }),
|
||||
// /frais-operations sans investissement_id ne connaît lui aussi que l'utilisateur —
|
||||
// même pattern, filtré côté client via invById.
|
||||
api.get('/frais-operations').catch(() => []),
|
||||
]);
|
||||
setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr);
|
||||
setAllReinvests(Array.isArray(reinvests) ? reinvests : []);
|
||||
setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []);
|
||||
} catch (e) {
|
||||
console.error('DepotsRetraits load error:', e);
|
||||
} finally { setLoading(false); }
|
||||
@@ -453,6 +458,48 @@ export default function DepotsRetraits() {
|
||||
return true;
|
||||
}), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]);
|
||||
|
||||
// Frais d'opération (Étape 3, 31/08/26) : une ligne débit par frais_operations, rattachée à
|
||||
// l'investissement concerné via investissement_id → invById (même pattern que les
|
||||
// réinvestissements : la route ne connaît que l'utilisateur, filtrage client via allInv déjà
|
||||
// scopé par investisseur actif). 'compte_courant' est affiché quand même (traçabilité) mais
|
||||
// n'a aucun effet sur le solde (cf. signedMontant dans MouvementsShared.jsx).
|
||||
const normalizedFraisMouvements = useMemo(() => {
|
||||
const out = [];
|
||||
for (const fr of allFraisOps) {
|
||||
const inv = invById.get(fr.investissement_id);
|
||||
if (!inv) continue; // hors scope investisseur actif
|
||||
out.push({
|
||||
id: `frais_${fr.id}`,
|
||||
_fraisId: fr.id,
|
||||
_invId: inv.id,
|
||||
_rembId: fr.remboursement_id || null,
|
||||
plateforme_id: inv.plateforme_id,
|
||||
plateforme_nom: inv.plateforme_nom,
|
||||
plateforme_detenteur_nom: inv.plateforme_detenteur_nom,
|
||||
date_operation: fr.date_operation,
|
||||
type: 'frais',
|
||||
montant: fr.montant,
|
||||
mode_reglement: fr.mode_reglement,
|
||||
compte_nom: fr.compte_nom || null,
|
||||
origine: fr.origine,
|
||||
categorie: fr.categorie,
|
||||
libelle: inv.nom_projet || '',
|
||||
source: 'frais_operation',
|
||||
investisseur_id: inv.investisseur_id ?? null,
|
||||
notes: fr.notes || '',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [allFraisOps, invById]);
|
||||
|
||||
const filteredFraisMouvements = useMemo(() => normalizedFraisMouvements.filter(r => {
|
||||
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
const [y, m] = r.date_operation.split('-');
|
||||
if (filter.year && y !== filter.year) return false;
|
||||
if (filter.month && m !== filter.month.padStart(2,'0')) return false;
|
||||
return true;
|
||||
}), [normalizedFraisMouvements, filter.plateforme_ids, filter.year, filter.month]);
|
||||
|
||||
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
|
||||
// COMPLET et NON FILTRÉ (allRows/normalizedCorrections/normalizedRembMouvements/
|
||||
// normalizedSouscriptionMouvements, pas rows/filteredCorrections/...), sinon le cumul
|
||||
@@ -461,7 +508,8 @@ export default function DepotsRetraits() {
|
||||
// Voir [[project_mouvements_solde_indicatif]].
|
||||
const soldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
|
||||
...allRows, ...normalizedCorrections, ...normalizedRembMouvements, ...normalizedSouscriptionMouvements,
|
||||
]), [allRows, normalizedCorrections, normalizedRembMouvements, normalizedSouscriptionMouvements]);
|
||||
...normalizedFraisMouvements,
|
||||
]), [allRows, normalizedCorrections, normalizedRembMouvements, normalizedSouscriptionMouvements, normalizedFraisMouvements]);
|
||||
|
||||
// tableRows : dépôts/retraits + corrections + remboursements/bonus + souscriptions, triés par date desc.
|
||||
// _projet : nom du projet quand cela a du sens (remboursement, souscription/réinvestissement,
|
||||
@@ -480,9 +528,12 @@ export default function DepotsRetraits() {
|
||||
const subPart = filteredSouscriptionMouvements
|
||||
.filter(r => !filter.type || r.type === filter.type)
|
||||
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||
return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart])
|
||||
const fraisPart = filteredFraisMouvements
|
||||
.filter(r => !filter.type || r.type === filter.type)
|
||||
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||
return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart])
|
||||
.map(r => ({ ...r, _soldeIndicatif: soldeIndicatifByRowId.get(r.id) }));
|
||||
}, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]);
|
||||
}, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filteredFraisMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]);
|
||||
|
||||
/* ── Pagination mouvements ── */
|
||||
const {
|
||||
@@ -1296,6 +1347,7 @@ export default function DepotsRetraits() {
|
||||
<option value="cashback">Cashback</option>
|
||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||
<option value="souscription">Souscriptions</option>
|
||||
<option value="frais">Frais d'opération</option>
|
||||
<option value="prelevement_ir">Prélèvements IR</option>
|
||||
<option value="prelevement_csg">Prélèvements CSG</option>
|
||||
<option value="prelevement_local">Prélèvements étranger</option>
|
||||
@@ -1508,6 +1560,55 @@ export default function DepotsRetraits() {
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (r.type === 'frais') {
|
||||
const horsPM = r.mode_reglement === 'compte_courant';
|
||||
const rembLinked = r.mode_reglement === 'remboursement';
|
||||
return (
|
||||
<tr key={r.id}
|
||||
className={`dr-row${selectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||
onClick={() => setSelectedRow(selectedRow?.id === r.id ? null : r)}
|
||||
style={{ cursor: 'pointer', opacity: horsPM ? 0.75 : 1 }}>
|
||||
<td>{fmtDate(r.date_operation)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: 'color-mix(in srgb, #f59e0b 15%, transparent)',
|
||||
color: '#f59e0b',
|
||||
}}>frais</span>
|
||||
{horsPM && (
|
||||
<span style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||
padding: '1px 5px', borderRadius: 3,
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-muted)',
|
||||
verticalAlign: 'middle',
|
||||
}} title="Réglé via un compte courant — aucun effet sur le solde du porte-monnaie">HORS PM</span>
|
||||
)}
|
||||
{rembLinked && (
|
||||
<span style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||
padding: '1px 5px', borderRadius: 3,
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-muted)',
|
||||
verticalAlign: 'middle',
|
||||
}} title="Déjà déduit du montant net du remboursement — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT REMB.</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted">{r._projet || '—'}</td>
|
||||
<td className={horsPM ? 'num text-muted' : 'num danger'}>
|
||||
− {fmtEUR(r.montant)}
|
||||
</td>
|
||||
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
|
||||
<td style={{ width: 36, textAlign: 'center' }} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
const isAuto = r.source === 'auto_remboursement';
|
||||
return (
|
||||
<tr key={r.id}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import RembFormModal from '../components/RembFormModal.jsx';
|
||||
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
|
||||
@@ -21,6 +22,44 @@ const emptyForm = {
|
||||
echeance_fin_de_mois: false,
|
||||
methode_remboursement: '', nom_compte_courant: '', compte_id: '',
|
||||
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||
// Frais liés à la plateforme (étape conditionnelle du formulaire, visible aussi en
|
||||
// modification depuis le 31/08/26 — cf. project_frais_operation.md). frais_id : id de
|
||||
// l'entrée frais_operations de catégorie 'souscription' déjà existante, pré-remplie par
|
||||
// openEdit ; pilote l'upsert au submit, jamais affiché tel quel dans le formulaire.
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||
};
|
||||
|
||||
/** Formulaire du bloc "Frais d'opération" (cf. project_frais_operation.md) — indépendant du
|
||||
* formulaire principal de l'investissement, sa propre modale légère. Réservé aux catégories
|
||||
* gestion/distribution/autre : la catégorie "souscription" est unique par investissement et
|
||||
* se saisit/modifie uniquement depuis l'étape "Frais" du formulaire "Modifier l'investissement"
|
||||
* (décision Olivier, 31/08/26) — ce bloc ne l'affiche qu'en lecture seule. Saisie libre : la
|
||||
* date par défaut est celle du jour (pas la date de souscription — revu le 31/08/26 après un
|
||||
* premier essai qui la calait sur la souscription, jugé incohérent maintenant que ce bloc ne
|
||||
* gère plus cette catégorie). */
|
||||
function emptyFraisForm() {
|
||||
return { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', origine: 'manuel', categorie: 'autre' };
|
||||
}
|
||||
|
||||
const FRAIS_MODE_LABELS = {
|
||||
source: 'Déduit du versement lors de la souscription',
|
||||
portefeuille: 'Porte-monnaie',
|
||||
compte_courant: 'Débité du compte courant',
|
||||
remboursement: 'Déduit du remboursement',
|
||||
};
|
||||
const FRAIS_ORIGINE_LABELS = {
|
||||
souscription: 'Souscription',
|
||||
remboursement: 'Remboursement',
|
||||
manuel: 'Manuel',
|
||||
};
|
||||
// Catégorie de frais (31/08/26, demande Olivier) — cf. FRAIS_MODE_LABELS ci-dessus pour le
|
||||
// pattern. "Souscription" n'apparaît jamais dans le sélecteur de ce bloc (cf. doc ci-dessus),
|
||||
// seulement dans ce libellé pour l'affichage en lecture seule des entrées existantes.
|
||||
const FRAIS_CATEGORIE_LABELS = {
|
||||
souscription: 'Souscription',
|
||||
gestion: 'Gestion',
|
||||
distribution: 'Distribution',
|
||||
autre: 'Autre',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -160,20 +199,37 @@ export default function InvestissementDetail() {
|
||||
const [docsMenu, setDocsMenu] = useState(null);
|
||||
const [docsErr, setDocsErr] = useState(null);
|
||||
const [uploadingDoc, setUploadingDoc] = useState(false);
|
||||
// Bloc "Frais d'opération" (traçabilité des frais liés à la plateforme, cf. project_frais_operation.md)
|
||||
const [fraisOperations, setFraisOperations] = useState([]);
|
||||
const [fraisMenu, setFraisMenu] = useState(null);
|
||||
const [fraisModalOpen, setFraisModalOpen] = useState(false);
|
||||
const [editingFraisId, setEditingFraisId] = useState(null);
|
||||
const [fraisForm, setFraisForm] = useState(emptyFraisForm());
|
||||
const [fraisErr, setFraisErr] = useState(null);
|
||||
const [comptesFraisInvestisseur, setComptesFraisInvestisseur] = useState([]);
|
||||
const [renamingDocId, setRenamingDocId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const docFileInputRef = useRef(null);
|
||||
|
||||
// Charge les comptes du détenteur pour le formulaire "Frais d'opération"
|
||||
useEffect(() => {
|
||||
if (!fraisModalOpen || !inv?.investisseur_id) { setComptesFraisInvestisseur([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
|
||||
.then(comptes => setComptesFraisInvestisseur(comptes))
|
||||
.catch(() => setComptesFraisInvestisseur([]));
|
||||
}, [fraisModalOpen]); /* eslint-disable-next-line */
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [data, p, comptes, catInv, sectInv, docs] = await Promise.all([
|
||||
const [data, p, comptes, catInv, sectInv, docs, fraisOps] = await Promise.all([
|
||||
api.get(`/investissements/${id}`),
|
||||
api.get('/plateformes'),
|
||||
api.get('/investissements/comptes-courants'),
|
||||
api.get('/categories-inv'),
|
||||
api.get('/secteurs-inv'),
|
||||
api.get('/documents', { entity_type: 'investissement', entity_id: id }),
|
||||
api.get('/frais-operations', { investissement_id: id }).catch(() => []),
|
||||
]);
|
||||
setInv(data);
|
||||
setPlats(p);
|
||||
@@ -181,9 +237,18 @@ export default function InvestissementDetail() {
|
||||
setCategoriesInv(catInv);
|
||||
setSecteursInv(sectInv);
|
||||
setDocuments(docs);
|
||||
setFraisOperations(fraisOps);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
/** Recharge uniquement la liste des frais d'opération (après ajout/édition/suppression). */
|
||||
const refreshFraisOperations = async () => {
|
||||
try {
|
||||
const fraisOps = await api.get('/frais-operations', { investissement_id: id });
|
||||
setFraisOperations(fraisOps);
|
||||
} catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ }
|
||||
};
|
||||
|
||||
/** Recharge uniquement la liste des documents (après upload/renommage/suppression) —
|
||||
* plus léger qu'un load() complet qui recharge aussi l'investissement, les plateformes,
|
||||
* les comptes courants et les catégories/secteurs. */
|
||||
@@ -258,6 +323,71 @@ export default function InvestissementDetail() {
|
||||
});
|
||||
};
|
||||
|
||||
/* ── Bloc "Frais d'opération" ──────────────────────────────────
|
||||
* Traçabilité des frais liés à la plateforme (entrée, gestion…), décorrélée de la modale
|
||||
* d'investissement — cf. project_frais_operation.md. Toute entrée créée ou modifiée ici est
|
||||
* indépendante de montant_investi : c'est à l'utilisateur d'ajuster ce dernier lui-même
|
||||
* (via "Modifier l'investissement") si un frais doit réduire le capital net.
|
||||
*/
|
||||
const openFraisModal = () => {
|
||||
setEditingFraisId(null);
|
||||
setFraisForm(emptyFraisForm());
|
||||
setFraisErr(null);
|
||||
setFraisModalOpen(true);
|
||||
};
|
||||
|
||||
// N'ouvre jamais cette modale pour une entrée de catégorie 'souscription' — le bouton
|
||||
// Modifier de la liste est masqué pour ces lignes (cf. rendu de la liste plus bas), donc
|
||||
// cette fonction n'est en pratique jamais appelée avec une telle entrée.
|
||||
const openEditFraisModal = (f) => {
|
||||
setEditingFraisId(f.id);
|
||||
setFraisForm({
|
||||
montant: f.montant,
|
||||
mode_reglement: f.mode_reglement,
|
||||
compte_id: f.compte_id || '',
|
||||
date_operation: f.date_operation,
|
||||
notes: f.notes || '',
|
||||
origine: f.origine || 'manuel',
|
||||
categorie: f.categorie || 'autre',
|
||||
});
|
||||
setFraisErr(null);
|
||||
setFraisModalOpen(true);
|
||||
};
|
||||
|
||||
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); };
|
||||
|
||||
const submitFrais = async (e) => {
|
||||
e?.preventDefault?.();
|
||||
setFraisErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
investissement_id: Number(id),
|
||||
montant: Number(fraisForm.montant),
|
||||
mode_reglement: fraisForm.mode_reglement,
|
||||
compte_id: fraisForm.mode_reglement === 'compte_courant' && fraisForm.compte_id ? Number(fraisForm.compte_id) : null,
|
||||
date_operation: fraisForm.date_operation,
|
||||
origine: fraisForm.origine || 'manuel',
|
||||
categorie: fraisForm.categorie || 'autre',
|
||||
notes: fraisForm.notes || undefined,
|
||||
};
|
||||
if (editingFraisId) await api.put(`/frais-operations/${editingFraisId}`, payload);
|
||||
else await api.post('/frais-operations', payload);
|
||||
closeFraisModal();
|
||||
await refreshFraisOperations();
|
||||
} catch (e) { setFraisErr(e.message); }
|
||||
};
|
||||
|
||||
const handleFraisDelete = (f) => {
|
||||
setRowDeleteConfirm({
|
||||
message: `Supprimer ce frais de ${fmtEUR(f.montant)} (${fmtDate(f.date_operation)}) ?`,
|
||||
onConfirm: async () => {
|
||||
await api.del(`/frais-operations/${f.id}`);
|
||||
setRowDeleteConfirm(null);
|
||||
await refreshFraisOperations();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocDeleteAll = () => {
|
||||
setDocsMenu(null);
|
||||
setRowDeleteConfirm({
|
||||
@@ -353,8 +483,10 @@ export default function InvestissementDetail() {
|
||||
const year = parseInt(dateStr.slice(0, 4), 10);
|
||||
return pfuRates.find(r => r.annee === year) ?? getLastKnownRates();
|
||||
};
|
||||
const computeInteretsNets = (f) =>
|
||||
round2((Number(f.interets_bruts) || 0) - (Number(f.prelev_sociaux) || 0) - (Number(f.prelev_forfaitaire) || 0));
|
||||
const computeInteretsNets = (f) => {
|
||||
const fraisReduit = (!f.frais_mode_reglement || f.frais_mode_reglement === 'remboursement') ? (Number(f.frais_ttc) || 0) : 0;
|
||||
return round2((Number(f.interets_bruts) || 0) - fraisReduit - (Number(f.prelev_sociaux) || 0) - (Number(f.prelev_forfaitaire) || 0));
|
||||
};
|
||||
const computeNet = (f) =>
|
||||
round2((Number(f.capital) || 0) + (Number(f.cashback) || 0) + computeInteretsNets(f));
|
||||
|
||||
@@ -375,9 +507,15 @@ export default function InvestissementDetail() {
|
||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
||||
}
|
||||
|
||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
|
||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
|
||||
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
||||
const bruts = Number(next.interets_bruts) || 0;
|
||||
// Base imposable nette de frais (Étape 4, 31/08/26) — même formule que computeChamps()
|
||||
// côté backend et que POST /reprocess : les prélèvements doivent porter sur
|
||||
// (interets_bruts - frais_ttc), pas sur le brut seul, sous peine de sur-taxer le coupon
|
||||
// (confirmé par Olivier sur l'avis d'opéré Kyoseil-AM/Fundora du 02/07/2026). Depuis
|
||||
// l'Étape 5 bis (01/09/26), uniquement si le frais est retenu SUR ce remboursement.
|
||||
const fraisReduit = (!next.frais_mode_reglement || next.frais_mode_reglement === 'remboursement') ? (Number(next.frais_ttc) || 0) : 0;
|
||||
const bruts = round2((Number(next.interets_bruts) || 0) - fraisReduit);
|
||||
const rates = getRatesForYear(dateStr);
|
||||
if (rates) {
|
||||
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
||||
@@ -407,6 +545,8 @@ export default function InvestissementDetail() {
|
||||
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
||||
taxe_locale,
|
||||
interets_bruts: brutsApresLocal,
|
||||
frais_mode_reglement: 'remboursement',
|
||||
frais_compte_id: '',
|
||||
prelev_sociaux: rates ? round2(baseRates * rates.prelev_sociaux / 100) : 0,
|
||||
prelev_forfaitaire: rates ? round2(baseRates * rates.impot_revenu / 100) : 0,
|
||||
statut: 'paye',
|
||||
@@ -420,6 +560,10 @@ export default function InvestissementDetail() {
|
||||
|
||||
const openEditRemb = (r) => {
|
||||
setEditingRembId(r.id);
|
||||
// Étape 5 bis (01/09/26) : le mode de règlement du frais n'est pas stocké sur le
|
||||
// remboursement lui-même mais sur la ligne frais_operations liée — recherchée dans
|
||||
// fraisOperations (déjà chargé pour le bloc 2b), par remboursement_id.
|
||||
const linkedFrais = fraisOperations.find(f => f.remboursement_id === r.id);
|
||||
setRembForm({
|
||||
date_remb: r.date_remb,
|
||||
capital: r.capital ?? 0,
|
||||
@@ -427,6 +571,9 @@ export default function InvestissementDetail() {
|
||||
interets_bruts_avant_local: r.interets_bruts_avant_local ?? 0,
|
||||
taxe_locale: r.taxe_locale ?? 0,
|
||||
interets_bruts: r.interets_bruts ?? 0,
|
||||
frais_ttc: r.frais_ttc ?? 0,
|
||||
frais_mode_reglement: linkedFrais?.mode_reglement || 'remboursement',
|
||||
frais_compte_id: String(linkedFrais?.compte_id || ''),
|
||||
prelev_sociaux: r.prelev_sociaux ?? 0,
|
||||
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
|
||||
statut: r.statut,
|
||||
@@ -451,6 +598,9 @@ export default function InvestissementDetail() {
|
||||
interets_bruts_avant_local: 0,
|
||||
taxe_locale: 0,
|
||||
interets_bruts: 0,
|
||||
frais_ttc: 0,
|
||||
frais_mode_reglement: 'remboursement',
|
||||
frais_compte_id: '',
|
||||
prelev_sociaux: 0,
|
||||
prelev_forfaitaire: 0,
|
||||
statut: 'paye',
|
||||
@@ -599,6 +749,9 @@ export default function InvestissementDetail() {
|
||||
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
|
||||
taxe_locale: Number(rembForm.taxe_locale || 0),
|
||||
interets_bruts: Number(rembForm.interets_bruts || 0),
|
||||
frais_ttc: Number(rembForm.frais_ttc || 0),
|
||||
frais_mode_reglement: rembForm.frais_mode_reglement || 'remboursement',
|
||||
frais_compte_id: rembForm.frais_mode_reglement === 'compte_courant' && rembForm.frais_compte_id ? Number(rembForm.frais_compte_id) : null,
|
||||
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
|
||||
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
|
||||
statut: rembForm.statut,
|
||||
@@ -624,7 +777,7 @@ export default function InvestissementDetail() {
|
||||
} catch (e) { setConfirmingRembDelete(false); setRembErr(e.message); }
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
const openEdit = async () => {
|
||||
const plat = plats.find(p => p.id === inv.plateforme_id);
|
||||
const platMethode = plat?.methode_remboursement;
|
||||
const resolvedMethode = platMethode && platMethode !== 'choix_investisseur'
|
||||
@@ -654,10 +807,31 @@ export default function InvestissementDetail() {
|
||||
pays_exposition: inv.pays_exposition || 'FR',
|
||||
categories_inv_ids: (inv.categories_inv || []).map(c => c.id),
|
||||
secteurs_inv_ids: (inv.secteurs_inv || []).map(s => s.id),
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||
});
|
||||
lastValidSouscriptionRef.current = inv.date_souscription || '';
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
|
||||
// Pré-remplissage de l'étape "Frais" avec le frais de souscription déjà documenté pour cet
|
||||
// investissement, s'il existe — même logique que Investissements.jsx (cf.
|
||||
// project_frais_operation.md, catégorie 'souscription' unique par investissement).
|
||||
try {
|
||||
const fraisOps = await api.get('/frais-operations', { investissement_id: inv.id });
|
||||
const souscriptionFrais = (fraisOps || []).find(f => f.categorie === 'souscription');
|
||||
if (souscriptionFrais) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
frais_mode_saisie: 'montant',
|
||||
frais_valeur: souscriptionFrais.montant,
|
||||
frais_mode_reglement: souscriptionFrais.mode_reglement,
|
||||
frais_compte_id: souscriptionFrais.compte_id || '',
|
||||
frais_id: souscriptionFrais.id,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Pré-remplissage best-effort — une erreur réseau ne doit pas empêcher l'édition.
|
||||
}
|
||||
};
|
||||
|
||||
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement héritée
|
||||
@@ -678,10 +852,15 @@ export default function InvestissementDetail() {
|
||||
methode_remboursement: newMethode, compte_id: '',
|
||||
categories_inv_ids: platCats.map(c => c.id),
|
||||
secteurs_inv_ids: platSects.map(s => s.id),
|
||||
// Réinitialise la saisie de l'étape "Frais" (le référentiel de frais dépend de la
|
||||
// plateforme) — frais_id (l'entrée existante éventuelle) n'est PAS réinitialisé : il
|
||||
// reste lié à cet investissement, pas à la plateforme sélectionnée dans le formulaire.
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '',
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '' }));
|
||||
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '',
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -703,6 +882,20 @@ export default function InvestissementDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frais liés à la plateforme (étape conditionnelle du formulaire, cf.
|
||||
// project_frais_operation.md) : montant_investi n'est JAMAIS réajusté automatiquement
|
||||
// ici (édition uniquement sur cette page — seule la création, dans Investissements.jsx,
|
||||
// ajuste le capital net) ; le frais est journalisé à part, en upsert (POST/PUT/DELETE
|
||||
// selon form.frais_id) après l'enregistrement principal.
|
||||
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||
const hasFrais = platFraisType && platFraisType !== 'aucun'
|
||||
&& form.frais_valeur !== '' && Number(form.frais_valeur) > 0;
|
||||
const fraisTTC = hasFrais
|
||||
? Math.round((form.frais_mode_saisie === 'pourcentage'
|
||||
? Number(form.montant_investi) * Number(form.frais_valeur) / 100
|
||||
: Number(form.frais_valeur)) * 100) / 100
|
||||
: 0;
|
||||
|
||||
const payload = {
|
||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||
plateforme_id: Number(form.plateforme_id),
|
||||
@@ -731,8 +924,32 @@ export default function InvestissementDetail() {
|
||||
api.put(`/investissements/${id}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
||||
api.put(`/investissements/${id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||
]);
|
||||
|
||||
// Upsert du frais de souscription (catégorie unique par investissement) — même logique
|
||||
// que Investissements.jsx (cf. project_frais_operation.md).
|
||||
try {
|
||||
if (hasFrais) {
|
||||
const fraisPayload = {
|
||||
investissement_id: Number(id),
|
||||
montant: fraisTTC,
|
||||
mode_reglement: form.frais_mode_reglement,
|
||||
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||
date_operation: form.date_souscription,
|
||||
origine: 'souscription',
|
||||
categorie: 'souscription',
|
||||
};
|
||||
if (form.frais_id) await api.put(`/frais-operations/${form.frais_id}`, fraisPayload);
|
||||
else await api.post('/frais-operations', fraisPayload);
|
||||
} else if (form.frais_id) {
|
||||
await api.del(`/frais-operations/${form.frais_id}`);
|
||||
}
|
||||
} catch {
|
||||
// Non bloquant — rattrapable a posteriori depuis le bloc "Frais d'opération".
|
||||
}
|
||||
|
||||
close();
|
||||
await load();
|
||||
await refreshFraisOperations();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
@@ -886,6 +1103,12 @@ export default function InvestissementDetail() {
|
||||
const autoReinvActive = !!inv?.auto_reinvest;
|
||||
const hasReinvests = reinvs.length > 0 || autoReinvActive;
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
// Bloc "Frais d'opération" : visible si la plateforme facture des frais (référentiel
|
||||
// frais_plateforme ≠ 'aucun'), ou s'il existe déjà des frais journalisés (garde l'accès même
|
||||
// si la config de la plateforme change ensuite) — cf. project_frais_operation.md.
|
||||
const platFraisType = plats.find(p => p.id === inv?.plateforme_id)?.frais_plateforme;
|
||||
const showFraisCard = (platFraisType && platFraisType !== 'aucun') || fraisOperations.length > 0;
|
||||
const totalFrais = fraisOperations.reduce((sum, f) => sum + (f.montant || 0), 0);
|
||||
// Calculés depuis les remboursements chargés (la route /:id ne retourne pas ces agrégats)
|
||||
const capitalRembourse = remb.reduce((s, r) => s + (r.capital || 0), 0);
|
||||
// Capital déclaré en perte définitive (0 si non déclaré) — déduit du capital restant dû
|
||||
@@ -1036,6 +1259,13 @@ export default function InvestissementDetail() {
|
||||
const reinvOutflows = reinvs.map(r => ({ amount: -r.montant, date: r.date_reinvestissement }));
|
||||
// Valorisation à date du capital pas encore remboursé (uniquement pour l'estimation en cours)
|
||||
const valorisationAuj = rendementEstime && capRestant > 0 ? [{ amount: capRestant, date: todayStr }] : [];
|
||||
// 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, à compter comme un flux négatif
|
||||
// dans le XIRR NET uniquement (le brut reste "avant tout frais").
|
||||
const fraisOutflows = fraisOperations
|
||||
.filter(f => f.mode_reglement === 'portefeuille' || f.mode_reglement === 'compte_courant')
|
||||
.map(f => ({ amount: -(f.montant || 0), date: f.date_operation }));
|
||||
// Brut : capital + cashback + intérêts bruts (avant fiscalité)
|
||||
const cashflowsBrut = [
|
||||
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
||||
@@ -1050,6 +1280,7 @@ export default function InvestissementDetail() {
|
||||
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
||||
...reinvOutflows,
|
||||
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
|
||||
...fraisOutflows,
|
||||
...valorisationAuj,
|
||||
];
|
||||
rendementReel = xirr(cashflows);
|
||||
@@ -1068,6 +1299,7 @@ export default function InvestissementDetail() {
|
||||
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
||||
...reinvOutflows,
|
||||
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
|
||||
...fraisOutflows,
|
||||
];
|
||||
for (const s of futureEcheances) {
|
||||
const rates = getRatesForYear(s.date_prevue);
|
||||
@@ -1159,10 +1391,11 @@ export default function InvestissementDetail() {
|
||||
{(() => {
|
||||
const rendement = netMode ? rendementReel : rendementReelBrut;
|
||||
const rendementAttenduAffiche = netMode ? rendementAttendu : rendementAttenduBrut;
|
||||
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 des flux réels datés ` +
|
||||
`(versement initial, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
|
||||
`Chaque flux est pondéré par sa date exacte — deux prêts au même total mais versés à des rythmes différents auront un XIRR différent.\n\n` +
|
||||
`Chaque flux est pondéré par sa date exacte — deux prêts au même total mais versés à des rythmes différents auront un XIRR différent.` + (netMode ? fraisExplication : '') + `\n\n` +
|
||||
(rendementEstime
|
||||
? "Prêt pas encore soldé : le capital restant dû est ajouté en flux final, valorisé à la date du jour — c'est donc une estimation, pas un taux définitif."
|
||||
: 'Prêt intégralement remboursé : taux définitif.');
|
||||
@@ -1171,7 +1404,7 @@ export default function InvestissementDetail() {
|
||||
`(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) encore à venir 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` +
|
||||
`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. Si ce ` +
|
||||
`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.`;
|
||||
return (
|
||||
@@ -1552,6 +1785,102 @@ export default function InvestissementDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Frais d'opération ──────────────────────────────────────── */}
|
||||
{showFraisCard && (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Frais d'opération
|
||||
{fraisOperations.length > 0 && (
|
||||
<span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
|
||||
Total : {fmtEUR(totalFrais)}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setFraisMenu({ x: r.right, y: r.bottom }); }}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
title="Actions"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fraisOperations.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
|
||||
Aucun frais journalisé pour l'instant. Utilisez le menu <strong>⋮</strong> ci-dessus pour en ajouter un.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{fraisOperations.map(f => {
|
||||
// Catégorie 'souscription' : unique par investissement, modifiable uniquement
|
||||
// depuis l'étape "Frais" de "Modifier l'investissement" (décision Olivier,
|
||||
// 31/08/26) — le clic et l'icône Modifier redirigent vers ce formulaire plutôt
|
||||
// que d'ouvrir la modale légère de ce bloc.
|
||||
const isSouscription = f.categorie === 'souscription';
|
||||
// mode_reglement 'remboursement' (Étape 4, 31/08/26) : ligne générée/synchronisée
|
||||
// automatiquement depuis le champ "Frais" d'un remboursement (frais_ttc) — montant
|
||||
// déjà pris en compte dans le calcul fiscal du remboursement, donc jamais éditable
|
||||
// ni supprimable directement ici (désynchroniserait les deux). Redirige vers le
|
||||
// remboursement source, comme pour la catégorie 'souscription' vers le formulaire.
|
||||
// Étape 5 bis (01/09/26) : "lié à un remboursement" ne veut plus dire
|
||||
// mode_reglement === 'remboursement' — le frais peut être réglé à part
|
||||
// (portefeuille/compte courant) tout en restant géré depuis le remboursement.
|
||||
const isRembLinked = f.remboursement_id != null;
|
||||
const linkedRemb = isRembLinked ? remb.find(r => r.id === f.remboursement_id) : null;
|
||||
const goToLinkedRemb = () => { if (linkedRemb) openEditRemb(linkedRemb); };
|
||||
const isManaged = isSouscription || isRembLinked;
|
||||
const manageAction = isSouscription ? openEdit : goToLinkedRemb;
|
||||
const manageTitle = isSouscription
|
||||
? "Modifiable depuis « Modifier l'investissement »"
|
||||
: isRembLinked
|
||||
? 'Modifiable depuis le remboursement lié'
|
||||
: 'Cliquer pour modifier';
|
||||
return (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<PageIcon name="frais" size={45} style={{ marginRight: 0, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}
|
||||
onClick={() => isManaged ? manageAction() : openEditFraisModal(f)}
|
||||
title={isManaged ? manageTitle : 'Cliquer pour modifier'}>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)' }}>
|
||||
{fmtEUR(f.montant)}
|
||||
<span className="text-muted" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||
{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}
|
||||
{f.mode_reglement === 'compte_courant' && f.compte_nom ? ` (${f.compte_nom})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{fmtDate(f.date_operation)} · {FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}
|
||||
{f.notes ? ` · ${f.notes}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
title={isManaged ? manageTitle : 'Modifier'}
|
||||
onClick={() => isManaged ? manageAction() : openEditFraisModal(f)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, padding: 0, background: 'none', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
{isRembLinked ? (
|
||||
<span title="Géré depuis le remboursement — modifiez ou videz le champ « Frais » du remboursement pour le retirer"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, flexShrink: 0, color: 'var(--text-muted)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" title="Supprimer" onClick={() => handleFraisDelete(f)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, padding: 0, background: 'none', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--danger)', flexShrink: 0 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Remboursements enregistrés ────────────────────────────── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
@@ -1621,17 +1950,46 @@ export default function InvestissementDetail() {
|
||||
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
|
||||
<td className="num">{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}</td>
|
||||
)}
|
||||
<td
|
||||
className={tauxImpliciteApplicable ? 'num cell-tooltip' : 'num'}
|
||||
data-tooltip={(() => {
|
||||
if (!tauxImpliciteApplicable) return undefined;
|
||||
{(() => {
|
||||
// Icône uniquement si le frais réduit réellement ce montant (mode
|
||||
// 'remboursement', par défaut) — cf. RembFormModal.jsx, Étape 5 bis.
|
||||
const showFraisIcon = !netMode && !!r.frais_ttc && (!r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement');
|
||||
const tooltipParts = [];
|
||||
if (tauxImpliciteApplicable) {
|
||||
const ti = tauxImpliciteByRembId.get(r.id);
|
||||
if (!ti || !(ti.base > 0)) return 'Taux implicite non calculable (capital restant nul ou inconnu)';
|
||||
return `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`;
|
||||
})()}
|
||||
>
|
||||
{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}
|
||||
</td>
|
||||
tooltipParts.push(!ti || !(ti.base > 0)
|
||||
? 'Taux implicite non calculable (capital restant nul ou inconnu)'
|
||||
: `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`);
|
||||
}
|
||||
if (showFraisIcon) {
|
||||
tooltipParts.push(
|
||||
`Intérêts bruts avant frais : ${fmtEUR(r.interets_bruts)}\n` +
|
||||
`Frais de distribution : -${fmtEUR(r.frais_ttc)}\n` +
|
||||
`Base après frais (avant prélèvements) : ${fmtEUR(round2(r.interets_bruts - r.frais_ttc))}\n` +
|
||||
`Prélèv. sociaux : ${fmtEUR(r.prelev_sociaux)}\n` +
|
||||
`Impôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}\n` +
|
||||
`Montant net reçu : ${fmtEUR(r.net_recu)}`
|
||||
);
|
||||
}
|
||||
const tooltip = tooltipParts.length ? tooltipParts.join('\n\n') : undefined;
|
||||
return (
|
||||
<td
|
||||
className={tooltip ? 'num cell-tooltip' : 'num'}
|
||||
data-tooltip={tooltip}
|
||||
>
|
||||
{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}
|
||||
{showFraisIcon && (
|
||||
<span style={{ marginLeft: 5, color: 'var(--warning, #b45309)', verticalAlign: 'middle' }}>
|
||||
<svg width="13" height="13" 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>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
<td className="num">{fmtEUR(r.cashback)}</td>
|
||||
{showImpositionDetail && <td className="num">{fmtEUR(r.prelev_forfaitaire)}</td>}
|
||||
{showImpositionDetail && <td className="num">{fmtEUR(r.prelev_sociaux)}</td>}
|
||||
@@ -2879,6 +3237,98 @@ export default function InvestissementDetail() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Menu ⋮ bloc "Frais d'opération" ── */}
|
||||
{fraisMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setFraisMenu(null)} />
|
||||
<div style={{
|
||||
position: 'fixed', left: fraisMenu.x, top: fraisMenu.y,
|
||||
transform: 'translateX(-100%) translateY(4px)',
|
||||
zIndex: 300,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
padding: '4px 0', minWidth: 200,
|
||||
}}>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => { setFraisMenu(null); openFraisModal(); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Ajouter un frais
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Modale "Frais d'opération" (ajout / édition) ─────────────── */}
|
||||
<Modal
|
||||
open={fraisModalOpen}
|
||||
title={editingFraisId ? 'Modifier le frais' : 'Ajouter un frais'}
|
||||
onClose={closeFraisModal}
|
||||
width={480}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" onClick={closeFraisModal}>Annuler</button>
|
||||
<button className="primary" onClick={submitFrais}>{editingFraisId ? 'Enregistrer' : 'Ajouter'}</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submitFrais}>
|
||||
{fraisErr && <div className="error">{fraisErr}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Montant TTC (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={fraisForm.montant}
|
||||
onChange={e => setFraisForm(f => ({ ...f, montant: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Date de l'opération *</label>
|
||||
<input type="date" required value={fraisForm.date_operation}
|
||||
onChange={e => setFraisForm(f => ({ ...f, date_operation: e.target.value }))} />
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Catégorie</label>
|
||||
<select value={fraisForm.categorie} onChange={e => setFraisForm(f => ({ ...f, categorie: e.target.value }))}>
|
||||
<option value="gestion">Gestion</option>
|
||||
<option value="distribution">Distribution</option>
|
||||
<option value="autre">Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Prélèvement</label>
|
||||
<select value={fraisForm.mode_reglement}
|
||||
onChange={e => setFraisForm(f => ({ ...f, mode_reglement: e.target.value, compte_id: e.target.value === 'compte_courant' ? f.compte_id : '' }))}>
|
||||
<option value="source">À la source (sans mouvement complémentaire)</option>
|
||||
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
|
||||
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
|
||||
</select>
|
||||
</div>
|
||||
{fraisForm.mode_reglement === 'compte_courant' && (
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Compte de règlement *</label>
|
||||
{comptesFraisInvestisseur.length > 0 ? (
|
||||
<select required value={fraisForm.compte_id} onChange={e => setFraisForm(f => ({ ...f, compte_id: e.target.value }))}>
|
||||
<option value="">— Choisir un compte —</option>
|
||||
{comptesFraisInvestisseur.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Notes</label>
|
||||
<input value={fraisForm.notes} onChange={e => setFraisForm(f => ({ ...f, notes: e.target.value }))} placeholder="Précisions optionnelles…" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!rowDeleteConfirm}
|
||||
message={rowDeleteConfirm?.message}
|
||||
|
||||
@@ -239,6 +239,11 @@ const empty = {
|
||||
statut: 'en_cours', reference: '', notes: '',
|
||||
echeance_fin_de_mois: false, methode_remboursement: '', nom_compte_courant: '', compte_id: '', pays_exposition: 'FR',
|
||||
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||
// Frais liés à la plateforme (étape conditionnelle, visible création ET modification —
|
||||
// cf. project_frais_operation.md). frais_id : id de l'entrée frais_operations de catégorie
|
||||
// 'souscription' déjà existante pour cet investissement (pré-remplie par openEdit), null si
|
||||
// aucune — pilote l'upsert (POST/PUT/DELETE) au submit, jamais affiché dans le formulaire.
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||
};
|
||||
|
||||
/* ── Composant principal ─────────────────────────────────────── */
|
||||
@@ -702,9 +707,31 @@ export default function Investissements() {
|
||||
pays_exposition: row.pays_exposition || 'FR',
|
||||
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
|
||||
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||
});
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
|
||||
// Pré-remplissage de l'étape "Frais" avec le frais de souscription déjà documenté pour cet
|
||||
// investissement, s'il existe (catégorie 'souscription', unique par investissement — cf.
|
||||
// project_frais_operation.md). Chargé après l'ouverture de la modale pour ne pas bloquer
|
||||
// l'affichage ; mise à jour du form dès la réponse.
|
||||
try {
|
||||
const fraisOps = await api.get('/frais-operations', { investissement_id: row.id });
|
||||
const souscriptionFrais = (fraisOps || []).find(f => f.categorie === 'souscription');
|
||||
if (souscriptionFrais) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
frais_mode_saisie: 'montant',
|
||||
frais_valeur: souscriptionFrais.montant,
|
||||
frais_mode_reglement: souscriptionFrais.mode_reglement,
|
||||
frais_compte_id: souscriptionFrais.compte_id || '',
|
||||
frais_id: souscriptionFrais.id,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Pré-remplissage best-effort — une erreur réseau ne doit pas empêcher l'édition.
|
||||
}
|
||||
};
|
||||
|
||||
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement,
|
||||
@@ -724,10 +751,12 @@ export default function Investissements() {
|
||||
methode_remboursement: newMethode, compte_id: '',
|
||||
categories_inv_ids: platCats.map(c => c.id),
|
||||
secteurs_inv_ids: platSects.map(s => s.id),
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '',
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
|
||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [],
|
||||
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -751,6 +780,26 @@ export default function Investissements() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frais liés à la plateforme (étape conditionnelle, création ET modification — le bloc
|
||||
// "Frais d'opération" de la fiche investissement reste le lieu pour les frais de
|
||||
// catégorie gestion/distribution/autre, cf. project_frais_operation.md) : à la création,
|
||||
// si prélevés "à la source", le capital net (montant investi diminué des frais) est ce
|
||||
// qui est enregistré comme montant_investi ; en modification, montant_investi n'est
|
||||
// JAMAIS réajusté automatiquement (demande explicite d'Olivier, 31/08/26) — c'est à
|
||||
// l'utilisateur de l'ajuster lui-même à l'étape 1 si besoin. Dans les deux cas, le frais
|
||||
// est journalisé (POST/PUT/DELETE selon form.frais_id) après l'enregistrement principal.
|
||||
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||
const hasFrais = platFraisType && platFraisType !== 'aucun'
|
||||
&& form.frais_valeur !== '' && Number(form.frais_valeur) > 0;
|
||||
const fraisTTC = hasFrais
|
||||
? Math.round((form.frais_mode_saisie === 'pourcentage'
|
||||
? Number(form.montant_investi) * Number(form.frais_valeur) / 100
|
||||
: Number(form.frais_valeur)) * 100) / 100
|
||||
: 0;
|
||||
const montantInvestiFinal = !editingId && hasFrais && form.frais_mode_reglement === 'source'
|
||||
? Math.max(0, Math.round((Number(form.montant_investi) - fraisTTC) * 100) / 100)
|
||||
: Number(form.montant_investi);
|
||||
|
||||
const payload = {
|
||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||
plateforme_id: Number(form.plateforme_id),
|
||||
@@ -759,7 +808,7 @@ export default function Investissements() {
|
||||
date_souscription: form.date_souscription,
|
||||
date_premiere_echeance: form.date_premiere_echeance || '',
|
||||
date_cible: form.date_cible || '',
|
||||
montant_investi: Number(form.montant_investi),
|
||||
montant_investi: montantInvestiFinal,
|
||||
taux_interet: form.taux_interet === '' ? undefined : Number(form.taux_interet),
|
||||
duree_mois: form.duree_mois === '' ? undefined : Number(form.duree_mois),
|
||||
type_remb: form.type_remb || '',
|
||||
@@ -779,6 +828,31 @@ export default function Investissements() {
|
||||
api.put(`/investissements/${editingId}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
||||
api.put(`/investissements/${editingId}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||
]);
|
||||
|
||||
// Upsert du frais de souscription (catégorie unique par investissement) : PUT si déjà
|
||||
// chargé via form.frais_id (pré-rempli par openEdit), POST sinon ; DELETE si le montant
|
||||
// a été vidé alors qu'une entrée existait — best-effort, n'empêche jamais l'édition
|
||||
// principale de l'investissement.
|
||||
try {
|
||||
if (hasFrais) {
|
||||
const fraisPayload = {
|
||||
investissement_id: editingId,
|
||||
montant: fraisTTC,
|
||||
mode_reglement: form.frais_mode_reglement,
|
||||
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||
date_operation: form.date_souscription,
|
||||
origine: 'souscription',
|
||||
categorie: 'souscription',
|
||||
};
|
||||
if (form.frais_id) await api.put(`/frais-operations/${form.frais_id}`, fraisPayload);
|
||||
else await api.post('/frais-operations', fraisPayload);
|
||||
} else if (form.frais_id) {
|
||||
await api.del(`/frais-operations/${form.frais_id}`);
|
||||
}
|
||||
} catch {
|
||||
// Non bloquant — rattrapable a posteriori depuis le bloc "Frais d'opération".
|
||||
}
|
||||
|
||||
close(); await load();
|
||||
} else {
|
||||
const created = await api.post('/investissements', payload);
|
||||
@@ -787,6 +861,23 @@ export default function Investissements() {
|
||||
api.put(`/investissements/${created.id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||
]);
|
||||
|
||||
if (hasFrais) {
|
||||
try {
|
||||
await api.post('/frais-operations', {
|
||||
investissement_id: created.id,
|
||||
montant: fraisTTC,
|
||||
mode_reglement: form.frais_mode_reglement,
|
||||
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||
date_operation: form.date_souscription,
|
||||
origine: 'souscription',
|
||||
categorie: 'souscription',
|
||||
});
|
||||
} catch {
|
||||
// Frais non journalisés (erreur réseau…) — n'empêche pas la création de l'investissement,
|
||||
// qui a déjà pris en compte le capital net le cas échéant. Rattrapable a posteriori.
|
||||
}
|
||||
}
|
||||
|
||||
// Contrôle du porte-monnaie — création uniquement (jamais en édition). Le solde
|
||||
// recalculé APRÈS création tient déjà compte du capital de ce nouvel investissement
|
||||
// (formule officielle, cf. mémoire project_solde_portefeuille) : s'il ressort négatif,
|
||||
|
||||
@@ -298,6 +298,7 @@ export default function Plateformes() {
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [allDepots, setAllDepots] = useState([]);
|
||||
const [allCorrections, setAllCorrections] = useState([]);
|
||||
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via mvInvById)
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31'
|
||||
// (GET /dashboard[?asOf=]) — remplace le recalcul client historique de
|
||||
@@ -369,7 +370,7 @@ export default function Plateformes() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu] = await Promise.all([
|
||||
const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps] = await Promise.all([
|
||||
api.get('/investissements', scopeParams),
|
||||
api.get('/plateformes'),
|
||||
api.get('/remboursements', scopeParams),
|
||||
@@ -378,6 +379,9 @@ export default function Plateformes() {
|
||||
api.get('/corrections', scopeParams),
|
||||
api.get('/simul/all', scopeParams),
|
||||
api.get('/pfu'),
|
||||
// /frais-operations sans investissement_id ne connaît lui aussi que l'utilisateur —
|
||||
// même pattern que /reinvestissements, filtré côté client via mvInvById.
|
||||
api.get('/frais-operations').catch(() => []),
|
||||
]);
|
||||
setAllRows(rows);
|
||||
setPlats(p);
|
||||
@@ -387,6 +391,7 @@ export default function Plateformes() {
|
||||
setAllCorrections(corrections);
|
||||
setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []);
|
||||
setPfuRates(Array.isArray(pfu) ? pfu : []);
|
||||
setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []);
|
||||
} finally { setLoading(false); setDrillRefreshKey(k => k + 1); }
|
||||
};
|
||||
|
||||
@@ -457,9 +462,15 @@ export default function Plateformes() {
|
||||
if (k === 'taxe_locale') {
|
||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
||||
}
|
||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
|
||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
|
||||
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
||||
const bruts = Number(next.interets_bruts) || 0;
|
||||
// Base imposable nette de frais (Étape 4, 31/08/26) — même formule que computeChamps()
|
||||
// côté backend et que POST /reprocess : les prélèvements doivent porter sur
|
||||
// (interets_bruts - frais_ttc), pas sur le brut seul, sous peine de sur-taxer le coupon
|
||||
// (confirmé par Olivier sur l'avis d'opéré Kyoseil-AM/Fundora du 02/07/2026). Depuis
|
||||
// l'Étape 5 bis (01/09/26), uniquement si le frais est retenu SUR ce remboursement.
|
||||
const fraisReduit = (!next.frais_mode_reglement || next.frais_mode_reglement === 'remboursement') ? (Number(next.frais_ttc) || 0) : 0;
|
||||
const bruts = round2((Number(next.interets_bruts) || 0) - fraisReduit);
|
||||
const rates = getRatesForYear(dateStr);
|
||||
if (rates) {
|
||||
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
||||
@@ -474,6 +485,10 @@ export default function Plateformes() {
|
||||
const inv = allRows.find(i => i.id === r.investissement_id);
|
||||
setRembInv(inv || null);
|
||||
setRembEditingId(r.id);
|
||||
// Étape 5 bis (01/09/26) : le mode de règlement du frais n'est pas stocké sur le
|
||||
// remboursement lui-même mais sur la ligne frais_operations liée — recherchée dans
|
||||
// allFraisOps déjà chargé, par remboursement_id.
|
||||
const linkedFrais = allFraisOps.find(f => f.remboursement_id === r.id);
|
||||
setRembForm({
|
||||
date_remb: r.date_remb,
|
||||
capital: r.capital ?? 0,
|
||||
@@ -481,6 +496,9 @@ export default function Plateformes() {
|
||||
interets_bruts_avant_local: r.interets_bruts_avant_local ?? 0,
|
||||
taxe_locale: r.taxe_locale ?? 0,
|
||||
interets_bruts: r.interets_bruts ?? 0,
|
||||
frais_ttc: r.frais_ttc ?? 0,
|
||||
frais_mode_reglement: linkedFrais?.mode_reglement || 'remboursement',
|
||||
frais_compte_id: String(linkedFrais?.compte_id || ''),
|
||||
prelev_sociaux: r.prelev_sociaux ?? 0,
|
||||
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
|
||||
statut: r.statut,
|
||||
@@ -514,6 +532,9 @@ export default function Plateformes() {
|
||||
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
||||
taxe_locale,
|
||||
interets_bruts: brutsApresLocal,
|
||||
frais_ttc: 0,
|
||||
frais_mode_reglement: 'remboursement',
|
||||
frais_compte_id: '',
|
||||
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
|
||||
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
|
||||
statut: 'paye',
|
||||
@@ -549,6 +570,9 @@ export default function Plateformes() {
|
||||
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
|
||||
taxe_locale: Number(rembForm.taxe_locale || 0),
|
||||
interets_bruts: Number(rembForm.interets_bruts || 0),
|
||||
frais_ttc: Number(rembForm.frais_ttc || 0),
|
||||
frais_mode_reglement: rembForm.frais_mode_reglement || 'remboursement',
|
||||
frais_compte_id: rembForm.frais_mode_reglement === 'compte_courant' && rembForm.frais_compte_id ? Number(rembForm.frais_compte_id) : null,
|
||||
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
|
||||
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
|
||||
statut: rembForm.statut,
|
||||
@@ -892,6 +916,16 @@ export default function Plateformes() {
|
||||
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 chartRows) {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
||||
@@ -907,7 +941,7 @@ export default function Plateformes() {
|
||||
flowsBrut.sort(byDate);
|
||||
flowsNet.sort(byDate);
|
||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
|
||||
}, [chartRows, allRembs, allReinvests, selectedYear, reinvestCumulParInv, capRembParInv]);
|
||||
}, [chartRows, allRembs, allReinvests, allFraisOps, selectedYear, reinvestCumulParInv, capRembParInv]);
|
||||
|
||||
/* ── XIRR "attendu" (théorique) — mêmes flux réels que ci-dessus, mais les prêts
|
||||
non soldés sont valorisés via leur échéancier prévisionnel (simul_remboursements)
|
||||
@@ -944,6 +978,16 @@ export default function Plateformes() {
|
||||
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 });
|
||||
}
|
||||
// Échéances futures prévues, groupées par investissement
|
||||
const futureSimulParInv = {};
|
||||
for (const sr of allSimulRembs) {
|
||||
@@ -977,7 +1021,7 @@ export default function Plateformes() {
|
||||
flowsBrut.sort(byDate);
|
||||
flowsNet.sort(byDate);
|
||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
|
||||
}, [chartRows, allRembs, allReinvests, allSimulRembs, selectedYear, reinvestCumulParInv, capRembParInv, pfuRates]);
|
||||
}, [chartRows, allRembs, allReinvests, allFraisOps, allSimulRembs, selectedYear, reinvestCumulParInv, capRembParInv, pfuRates]);
|
||||
|
||||
const netMode = displayMode === 'net';
|
||||
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1;
|
||||
@@ -1235,6 +1279,45 @@ export default function Plateformes() {
|
||||
return true;
|
||||
}), [mvNormalizedSouscriptionMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
||||
|
||||
// Frais d'opération (Étape 3, 31/08/26) — réplique de normalizedFraisMouvements dans
|
||||
// DepotsRetraits.jsx, cf. commentaire d'en-tête de ce bloc ONGLET "MOUVEMENTS PORTE-MONNAIE".
|
||||
const mvNormalizedFraisMouvements = useMemo(() => {
|
||||
const out = [];
|
||||
for (const fr of allFraisOps) {
|
||||
const inv = mvInvById.get(fr.investissement_id);
|
||||
if (!inv) continue;
|
||||
out.push({
|
||||
id: `frais_${fr.id}`,
|
||||
_fraisId: fr.id,
|
||||
_invId: inv.id,
|
||||
_rembId: fr.remboursement_id || null,
|
||||
plateforme_id: inv.plateforme_id,
|
||||
plateforme_nom: inv.plateforme_nom,
|
||||
plateforme_detenteur_nom: inv.plateforme_detenteur_nom,
|
||||
date_operation: fr.date_operation,
|
||||
type: 'frais',
|
||||
montant: fr.montant,
|
||||
mode_reglement: fr.mode_reglement,
|
||||
compte_nom: fr.compte_nom || null,
|
||||
origine: fr.origine,
|
||||
categorie: fr.categorie,
|
||||
libelle: inv.nom_projet || '',
|
||||
source: 'frais_operation',
|
||||
investisseur_id: inv.investisseur_id ?? null,
|
||||
notes: fr.notes || '',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [allFraisOps, mvInvById]);
|
||||
|
||||
const mvFilteredFraisMouvements = useMemo(() => mvNormalizedFraisMouvements.filter(r => {
|
||||
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
const [y, m] = r.date_operation.split('-');
|
||||
if (mvFilter.year && y !== mvFilter.year) return false;
|
||||
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
|
||||
return true;
|
||||
}), [mvNormalizedFraisMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
||||
|
||||
// Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais
|
||||
// pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours
|
||||
// d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les
|
||||
@@ -1245,13 +1328,14 @@ export default function Plateformes() {
|
||||
const set = new Set();
|
||||
for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||
for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||
for (const r of mvNormalizedFraisMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||
for (const d of allDepots) {
|
||||
if (!inScope(d.plateforme_id) || !d.remboursement_id) continue;
|
||||
const p = mvRembProjetById.get(d.remboursement_id);
|
||||
if (p) set.add(p);
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
|
||||
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
|
||||
|
||||
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
|
||||
// COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/
|
||||
@@ -1261,7 +1345,8 @@ export default function Plateformes() {
|
||||
// Voir [[project_mouvements_solde_indicatif]].
|
||||
const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
|
||||
...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements,
|
||||
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements]);
|
||||
...mvNormalizedFraisMouvements,
|
||||
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements]);
|
||||
|
||||
const mvTableRows = useMemo(() => {
|
||||
const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait';
|
||||
@@ -1276,11 +1361,14 @@ export default function Plateformes() {
|
||||
const subPart = mvFilteredSouscriptionMouvements
|
||||
.filter(r => !mvFilter.type || r.type === mvFilter.type)
|
||||
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||
const fraisPart = mvFilteredFraisMouvements
|
||||
.filter(r => !mvFilter.type || r.type === mvFilter.type)
|
||||
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||
return sortMouvements(
|
||||
[...drPart, ...corrPart, ...rembPart, ...subPart]
|
||||
[...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart]
|
||||
.filter(r => !mvFilter.projet || r._projet === mvFilter.projet)
|
||||
).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) }));
|
||||
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]);
|
||||
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]);
|
||||
|
||||
const {
|
||||
pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage,
|
||||
@@ -1641,10 +1729,11 @@ export default function Plateformes() {
|
||||
const rendementAttendu = netMode ? plateformeXirrAttendu.net : plateformeXirrAttendu.brut;
|
||||
const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui";
|
||||
const periodLabel = selectedYear ? `Sur l'année ${selectedYear}` : "Depuis le début";
|
||||
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 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` +
|
||||
`Chaque flux est pondéré par sa date exacte.` + (netMode ? fraisExplication : '') + `\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.');
|
||||
@@ -1653,7 +1742,7 @@ export default function Plateformes() {
|
||||
`(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é au ${cutoffLabel}", sans compter les intérêts futurs.\n\n` +
|
||||
`"comme racheté au ${cutoffLabel}", 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.`;
|
||||
@@ -2655,6 +2744,7 @@ export default function Plateformes() {
|
||||
<option value="cashback">Cashback</option>
|
||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||
<option value="souscription">Souscriptions</option>
|
||||
<option value="frais">Frais d'opération</option>
|
||||
<option value="prelevement_ir">Prélèvements IR</option>
|
||||
<option value="prelevement_csg">Prélèvements CSG</option>
|
||||
<option value="prelevement_local">Prélèvements étranger</option>
|
||||
@@ -2843,6 +2933,55 @@ export default function Plateformes() {
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (r.type === 'frais') {
|
||||
const horsPM = r.mode_reglement === 'compte_courant';
|
||||
const rembLinked = r.mode_reglement === 'remboursement';
|
||||
return (
|
||||
<tr key={r.id}
|
||||
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
|
||||
style={{ cursor: 'pointer', opacity: horsPM ? 0.75 : 1 }}>
|
||||
<td>{fmtDate(r.date_operation)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: 'color-mix(in srgb, #f59e0b 15%, transparent)',
|
||||
color: '#f59e0b',
|
||||
}}>frais</span>
|
||||
{horsPM && (
|
||||
<span style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||
padding: '1px 5px', borderRadius: 3,
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-muted)',
|
||||
verticalAlign: 'middle',
|
||||
}} title="Réglé via un compte courant — aucun effet sur le solde du porte-monnaie">HORS PM</span>
|
||||
)}
|
||||
{rembLinked && (
|
||||
<span style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||
padding: '1px 5px', borderRadius: 3,
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-muted)',
|
||||
verticalAlign: 'middle',
|
||||
}} title="Déjà déduit du montant net du remboursement — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT REMB.</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted">{r._projet || '—'}</td>
|
||||
<td className={horsPM ? 'num text-muted' : 'num danger'}>
|
||||
− {fmtEUR(r.montant)}
|
||||
</td>
|
||||
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
|
||||
<td style={{ width: 36, textAlign: 'center' }} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
const isAuto = r.source === 'auto_remboursement';
|
||||
return (
|
||||
<tr key={r.id}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useUi } from '../context/UiContext.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import InteretsChart from '../components/InteretsChart.jsx';
|
||||
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
||||
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
|
||||
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
|
||||
import DrillCellPanel from '../components/DrillCellPanel.jsx';
|
||||
import RembFormModal from '../components/RembFormModal.jsx';
|
||||
@@ -57,7 +59,8 @@ const empty = {
|
||||
capital: 0, cashback: 0,
|
||||
interets_bruts_avant_local: 0,
|
||||
taxe_locale: 0,
|
||||
interets_bruts: 0, prelev_sociaux: 0,
|
||||
interets_bruts: 0, frais_ttc: 0, frais_mode_reglement: 'remboursement', frais_compte_id: '',
|
||||
prelev_sociaux: 0,
|
||||
prelev_forfaitaire: 0,
|
||||
statut: 'paye', notes: '',
|
||||
methode_remboursement: 'portefeuille',
|
||||
@@ -181,6 +184,7 @@ export default function Remboursements() {
|
||||
const [corrections, setCorrections] = useState([]);
|
||||
const [investissements, setInvestissements] = useState([]);
|
||||
const [plateformes, setPlateformes] = useState([]);
|
||||
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs
|
||||
const [platLoaded, setPlatLoaded] = useState(false);
|
||||
const [pfuRates, setPfuRates] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -266,12 +270,16 @@ export default function Remboursements() {
|
||||
setSelectedSimul(''); // reset sélection projection
|
||||
try {
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
const [r, inv, corr] = await Promise.all([
|
||||
const [r, inv, corr, fraisOps] = await Promise.all([
|
||||
api.get('/remboursements', scopeParams),
|
||||
api.get('/investissements', scopeParams),
|
||||
api.get('/corrections', scopeParams).catch(() => []),
|
||||
// /frais-operations sans investissement_id ne connaît que l'utilisateur (tous
|
||||
// investisseurs), filtré ensuite via filterPlatId/rembPlatYear comme allRows —
|
||||
// même pattern que allFraisOps dans Plateformes.jsx (Étape 5).
|
||||
api.get('/frais-operations').catch(() => []),
|
||||
]);
|
||||
setAllRows(r); setInvestissements(inv); setCorrections(corr);
|
||||
setAllRows(r); setInvestissements(inv); setCorrections(corr); setAllFraisOps(fraisOps);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDrillRefreshKey(k => k + 1);
|
||||
@@ -412,6 +420,39 @@ export default function Remboursements() {
|
||||
[byPlatform]
|
||||
);
|
||||
|
||||
/* ── Étape 5 : reporting "Frais payés" — filtré comme le reste de la page par
|
||||
filterPlatId (plateforme) et rembPlatYear (année), sur date_operation cette fois. ── */
|
||||
const fraisFiltered = useMemo(() =>
|
||||
allFraisOps.filter(f => {
|
||||
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
|
||||
if (rembPlatYear && (f.date_operation || '').slice(0, 4) !== rembPlatYear) return false;
|
||||
return true;
|
||||
}),
|
||||
[allFraisOps, filterPlatId, rembPlatYear]
|
||||
);
|
||||
|
||||
const fraisPrevFiltered = useMemo(() =>
|
||||
allFraisOps.filter(f => {
|
||||
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
|
||||
return (f.date_operation || '').slice(0, 4) === rembPrevYear;
|
||||
}),
|
||||
[allFraisOps, filterPlatId, rembPrevYear]
|
||||
);
|
||||
|
||||
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
|
||||
const fraisPrevTotal = useMemo(() => fraisPrevFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisPrevFiltered]);
|
||||
|
||||
const byCategorie = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisFiltered) {
|
||||
const key = f.categorie || 'autre';
|
||||
if (!map[key]) map[key] = { categorie: key, montant: 0, count: 0 };
|
||||
map[key].montant += f.montant || 0;
|
||||
map[key].count += 1;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.montant - a.montant);
|
||||
}, [fraisFiltered]);
|
||||
|
||||
/* ── Tableau remboursements : filtrage client-side ── */
|
||||
const tableRows = useMemo(() => {
|
||||
// Remboursements filtrés
|
||||
@@ -547,7 +588,12 @@ export default function Remboursements() {
|
||||
capital: r.capital, cashback: r.cashback || 0,
|
||||
interets_bruts_avant_local: r.interets_bruts_avant_local || 0,
|
||||
taxe_locale: r.taxe_locale || 0,
|
||||
interets_bruts: r.interets_bruts,
|
||||
interets_bruts: r.interets_bruts, frais_ttc: r.frais_ttc || 0,
|
||||
// Étape 5 bis (01/09/26) : le mode de règlement du frais n'est pas stocké sur le
|
||||
// remboursement lui-même mais sur la ligne frais_operations liée — recherchée dans
|
||||
// allFraisOps déjà chargé, par remboursement_id (cf. RembFormModal.jsx).
|
||||
frais_mode_reglement: (allFraisOps.find(f => f.remboursement_id === r.id)?.mode_reglement) || 'remboursement',
|
||||
frais_compte_id: String(allFraisOps.find(f => f.remboursement_id === r.id)?.compte_id || ''),
|
||||
prelev_sociaux: r.prelev_sociaux, prelev_forfaitaire: r.prelev_forfaitaire,
|
||||
statut: r.statut, notes: r.notes || '',
|
||||
methode_remboursement: r.methode_remboursement || 'portefeuille',
|
||||
@@ -648,8 +694,10 @@ export default function Remboursements() {
|
||||
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
|
||||
const computeInteretsNets = (f) =>
|
||||
round2(Number(f.interets_bruts || 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
||||
const computeInteretsNets = (f) => {
|
||||
const fraisReduit = (!f.frais_mode_reglement || f.frais_mode_reglement === 'remboursement') ? Number(f.frais_ttc || 0) : 0;
|
||||
return round2(Number(f.interets_bruts || 0) - fraisReduit - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
||||
};
|
||||
|
||||
const computeNet = (f) =>
|
||||
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
|
||||
@@ -674,9 +722,17 @@ export default function Remboursements() {
|
||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
||||
}
|
||||
|
||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
|
||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
|
||||
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
||||
const bruts = Number(next.interets_bruts) || 0;
|
||||
// Base imposable nette de frais (Étape 4, 31/08/26) — même formule que computeChamps()
|
||||
// côté backend et que POST /reprocess : les prélèvements doivent porter sur
|
||||
// (interets_bruts - frais_ttc), pas sur le brut seul, sous peine de sur-taxer le coupon
|
||||
// (confirmé par Olivier sur l'avis d'opéré Kyoseil-AM/Fundora du 02/07/2026). Depuis
|
||||
// l'Étape 5 bis (01/09/26), cette réduction ne s'applique QUE si le frais est retenu SUR ce
|
||||
// remboursement (frais_mode_reglement === 'remboursement', par défaut) — un frais réglé à
|
||||
// part (portefeuille/compte courant) n'affecte pas cette base.
|
||||
const fraisReduit = (!next.frais_mode_reglement || next.frais_mode_reglement === 'remboursement') ? (Number(next.frais_ttc) || 0) : 0;
|
||||
const bruts = round2((Number(next.interets_bruts) || 0) - fraisReduit);
|
||||
const rates = getRatesForYear(dateStr);
|
||||
if (rates) {
|
||||
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
||||
@@ -743,6 +799,7 @@ export default function Remboursements() {
|
||||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
|
||||
taxe_locale: Number(form.taxe_locale || 0),
|
||||
interets_bruts: Number(form.interets_bruts || 0),
|
||||
frais_ttc: Number(form.frais_ttc || 0),
|
||||
prelev_sociaux: Number(form.prelev_sociaux || 0),
|
||||
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
|
||||
statut: form.statut,
|
||||
@@ -758,6 +815,9 @@ export default function Remboursements() {
|
||||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
|
||||
taxe_locale: Number(form.taxe_locale || 0),
|
||||
interets_bruts: Number(form.interets_bruts || 0),
|
||||
frais_ttc: Number(form.frais_ttc || 0),
|
||||
frais_mode_reglement: form.frais_mode_reglement || 'remboursement',
|
||||
frais_compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||
prelev_sociaux: Number(form.prelev_sociaux || 0),
|
||||
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
|
||||
statut: form.statut,
|
||||
@@ -881,6 +941,8 @@ export default function Remboursements() {
|
||||
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
|
||||
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('projections')}>Projections Échéances</button>
|
||||
<button className={`dr-tab${activeTab === 'frais' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('frais')}>Frais</button>
|
||||
</div>}
|
||||
|
||||
{/* ══ ONGLET PLATEFORMES ══ */}
|
||||
@@ -1265,7 +1327,35 @@ export default function Remboursements() {
|
||||
{(isBonusLike || isCorrection) ? '—' : fmtEUR(Math.max(0, r.capital_restant_du ?? 0))}
|
||||
</td>
|
||||
<td className="num">{r.cashback ? fmtEUR(r.cashback) : '—'}</td>
|
||||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.interets_bruts)}</td>
|
||||
<td className="num">
|
||||
{(isBonus || isCorrection) ? '—' : (
|
||||
<>
|
||||
{fmtEUR(r.interets_bruts)}
|
||||
{/* Icône uniquement si le frais réduit réellement ce montant (mode
|
||||
'remboursement', par défaut) — un frais réglé à part (Étape 5 bis,
|
||||
01/09/26) n'a aucun impact sur les intérêts bruts affichés ici. */}
|
||||
{!!r.frais_ttc && (!r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement') && (
|
||||
<span
|
||||
className="cell-tooltip"
|
||||
data-tooltip={
|
||||
`Intérêts bruts avant frais : ${fmtEUR(r.interets_bruts)}\n` +
|
||||
`Frais de distribution : -${fmtEUR(r.frais_ttc)}\n` +
|
||||
`Base après frais (avant prélèvements) : ${fmtEUR(round2(r.interets_bruts - r.frais_ttc))}\n` +
|
||||
`Prélèv. sociaux : ${fmtEUR(r.prelev_sociaux)}\n` +
|
||||
`Impôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}\n` +
|
||||
`Montant net reçu : ${fmtEUR(r.net_recu)}`
|
||||
}
|
||||
style={{ marginLeft: 5, color: 'var(--warning, #b45309)', verticalAlign: 'middle' }}>
|
||||
<svg width="13" height="13" 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_sociaux)}</td>
|
||||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_forfaitaire)}</td>
|
||||
<td className="num">
|
||||
@@ -1471,6 +1561,97 @@ export default function Remboursements() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET FRAIS (Étape 5 — reporting consolidé) ══ */}
|
||||
{activeTab === 'frais' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
{/* ── KPI ── */}
|
||||
<div className="dr-kpi-row" style={{ marginBottom: 16 }}>
|
||||
<div className="kpi">
|
||||
<div className="label">
|
||||
Frais payés{rembPlatYear && ` en ${rembPlatYear}`}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
|
||||
{rembPlatYear && <TrendBadge current={fraisTotal} prev={fraisPrevTotal} invert />}
|
||||
</div>
|
||||
{rembPlatYear && (
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
|
||||
{fmtEUR(fraisPrevTotal)} en {rembPrevYear}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Graphique ── */}
|
||||
<div className="charts-row" style={{ marginBottom: 16 }}>
|
||||
<FraisDistributionChart rows={fraisFiltered} />
|
||||
</div>
|
||||
|
||||
{/* ── Tableau par catégorie ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Répartition par catégorie
|
||||
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
|
||||
</h3>
|
||||
{filterPlatId && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Filtré sur cette plateforme
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Catégorie</th>
|
||||
<th className="num">Montant</th>
|
||||
<th className="num">Nombre</th>
|
||||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byCategorie.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byCategorie.map(c => {
|
||||
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
|
||||
return (
|
||||
<tr key={c.categorie}>
|
||||
<td>{FRAIS_CATEGORIE_LABELS[c.categorie] || c.categorie}</td>
|
||||
<td className="num">{fmtEUR(c.montant)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
|
||||
{poids.toFixed(1)} %
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{byCategorie.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {byCategorie.length} catégories</td>
|
||||
<td className="num">{fmtEUR(fraisTotal)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ MODAL ══ */}
|
||||
{(() => {
|
||||
// Liste d'investissements disponibles dans le select
|
||||
|
||||
Reference in new issue
Block a user