From 1d5737703bfc9f864a252d47800ebd55b992b0bf Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 19 Sep 2026 14:37:02 +0200 Subject: [PATCH] MAJ --- backend/src/routes/dashboard.js | 34 +++- backend/src/routes/dashboardPe.js | 17 +- backend/src/routes/investissementsPe.js | 153 ++++++++++++++++++ frontend/src/components/FraisFormModal.jsx | 13 +- .../components/InvestissementPeFormModal.jsx | 2 +- frontend/src/components/MouvementsShared.jsx | 41 ++++- frontend/src/pages/FraisPe.jsx | 1 + frontend/src/pages/InvestissementPeDetail.jsx | 1 + frontend/src/pages/Plateformes.jsx | 130 +++++++++++---- 9 files changed, 347 insertions(+), 45 deletions(-) diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index c45df87..925f559 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -144,13 +144,28 @@ router.get('/', (req, res) => { // (Fundora, 31/08/26) où la plateforme débite le montant BRUT, pas seulement le net investi. // 'portefeuille' est un versement complémentaire distinct de montant_investi, jamais câblé // jusqu'ici. 'compte_courant' reste exclu (aucun impact sur le porte-monnaie). + // Inclut désormais aussi les frais PE (investissement_pe_id) — jusqu'ici absents (LEFT JOIN + // ajouté le 19/09/26, bug trouvé par Claude en creusant le signalement Olivier du même jour + // sur le solde indicatif : une plateforme partagée crowdlending/PE voyait son solde_portefeuille + // légèrement surestimé dès qu'un frais PE "source"/"portefeuille" existait, ce frais n'étant + // jamais déduit nulle part). COALESCE sur investisseur_id : un frais n'a jamais les deux à la + // fois (CHECK en base), donc au plus un des deux LEFT JOIN matche réellement. + // Correctif du même jour (Olivier, après coup) : pour un deal PE, 'source' n'est PAS un débit + // supplémentaire — montant_investi (déjà déduit via capitalInvestiPePerPlat plus bas) est le + // montant BRUT transféré, frais compris. Ne compter un frais PE que s'il est 'portefeuille' + // (versement complémentaire, réellement distinct) ; côté crowdlending, 'source' reste compté + // (montant_investi y est net — cf. cas Fundora du 31/08/26, modèle différent de PE). const fraisPerPlat = db.prepare(` SELECT f.plateforme_id, COALESCE(SUM(f.montant), 0) AS total_frais FROM frais_operations f - JOIN investissements i ON i.id = f.investissement_id - WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} - AND f.mode_reglement IN ('source', 'portefeuille') + LEFT JOIN investissements i ON i.id = f.investissement_id + LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id + WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')} + AND ( + (i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille')) + OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille') + ) ${asOf ? 'AND f.date_operation <= ?' : ''} GROUP BY f.plateforme_id `).all(...invParams, ...(asOf ? [asOf] : [])); @@ -381,13 +396,18 @@ router.get('/solde-historique', (req, res) => { `).get(platId, ...invParams, date); /* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */ - // Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant. + // Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant, même + // exclusion des frais PE 'source' (déjà inclus dans montant_investi, cf. commentaire ci-dessus). const frais = db.prepare(` SELECT COALESCE(SUM(f.montant), 0) AS total_frais FROM frais_operations f - JOIN investissements i ON i.id = f.investissement_id - WHERE i.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')} - AND f.mode_reglement IN ('source', 'portefeuille') + LEFT JOIN investissements i ON i.id = f.investissement_id + LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id + WHERE f.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')} + AND ( + (i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille')) + OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille') + ) AND f.date_operation <= ? `).get(platId, ...invParams, date); diff --git a/backend/src/routes/dashboardPe.js b/backend/src/routes/dashboardPe.js index dd871e3..41444af 100644 --- a/backend/src/routes/dashboardPe.js +++ b/backend/src/routes/dashboardPe.js @@ -172,13 +172,24 @@ router.get('/', (req, res) => { // Frais d'opération et corrections de solde — concepts crowdlending // uniquement (cf. GET /api/dashboard), mais réduisent/ajustent le même // porte-monnaie physique sur une plateforme partagée. + // Même correctif que dashboard.js (19/09/26) : le JOIN ne portait que sur les investissements + // crowdlending, donc les frais PE (investissement_pe_id) n'étaient jamais déduits du solde + // porte-monnaie — surestimation silencieuse sur toute plateforme partagée avec des frais PE + // "source"/"portefeuille". LEFT JOIN + COALESCE des deux investisseur_id possibles. + // Correctif du même jour (Olivier, après coup) : en fait seul 'portefeuille' doit compter côté + // PE — 'source' est déjà inclus dans montant_investi (le montant BRUT transféré à la + // souscription), contrairement au crowdlending où montant_investi est net (cf. dashboard.js). const fraisPerPlat = db.prepare(` SELECT f.plateforme_id, COALESCE(SUM(f.montant), 0) AS total_frais FROM frais_operations f - JOIN investissements i ON i.id = f.investissement_id - WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} - AND f.mode_reglement IN ('source', 'portefeuille') + LEFT JOIN investissements i ON i.id = f.investissement_id + LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id + WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')} + AND ( + (i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille')) + OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille') + ) ${asOf ? 'AND f.date_operation <= ?' : ''} GROUP BY f.plateforme_id `).all(...invParams, ...(asOf ? [asOf] : [])); diff --git a/backend/src/routes/investissementsPe.js b/backend/src/routes/investissementsPe.js index 3227af4..54aab8e 100644 --- a/backend/src/routes/investissementsPe.js +++ b/backend/src/routes/investissementsPe.js @@ -181,6 +181,159 @@ router.get('/valorisations', (req, res) => { res.json(rows); }); +/** + * GET /api/investissements-pe/mouvements-portefeuille — 19/09/26, demande Olivier : "le suivi + * de mouvement du porte-monnaie doit être commun [crowdlending + PE], avec la possibilité de + * distinguer si l'on parle de crowdlending ou de PE". + * + * Contexte du bug signalé : l'onglet "Mouvements Porte-monnaie" (Plateformes.jsx, calcul du + * solde indicatif ligne à ligne via MouvementsShared.computeSoldeIndicatifMap) ne consomme que + * des endpoints bornés au workspace actif (GET /remboursements, /depots-retraits, + * /frais-operations, /investissements) — cohérent pour séparer la performance crowdlending de + * la performance PE, mais faux pour le porte-monnaie : sur une plateforme partagée (ex. + * Fundora), l'argent est UN SEUL porte-monnaie physique chez le courtier, alimenté ou ponctionné + * indifféremment par les deux activités. Un remboursement PE réutilisé pour souscrire un deal + * crowdlending devient alors invisible du côté crowdlending, et le cumul plonge artificiellement + * dans le négatif après chaque retrait — exactement le symptôme observé sur Fundora (3,45 €). + * + * Même principe déjà retenu pour solde_portefeuille (dashboard.js / dashboardPe.js, walletMap, + * 17/09/26) : cet endpoint-ci fournit la partie du porte-monnaie qui vit HORS du workspace + * actuellement actif (typiquement PE, quand appelé depuis Plateformes.jsx en workspace + * crowdlending), pour que le frontend la fusionne avec ce qu'il a déjà côté workspace actif — + * sans double-compte, et sans avoir à assouplir le scope workspace_id des endpoints existants. + * Quatre familles de lignes, déjà mises en forme proche de MouvementsShared (type/montant/ + * date_operation/plateforme_id/libelle) pour limiter le travail de fusion côté frontend : + * - souscription : capital déployé à la souscription de chaque deal PE (montant_investi), + * pas encore modélisé comme mouvement ailleurs (pendant PE de la ligne "souscription" + * synthétique que Plateformes.jsx génère déjà pour le crowdlending). + * - interets_plateforme : distributions PE (remboursements.type='interets_plateforme'), + * créditées pour leur net_recu — même convention que interetsPlateformeWalletPerPlat dans + * dashboardPe.js ("net_recu tient déjà compte des prélèvements"). + * - frais : frais_operations rattachés à investissement_pe_id, jusqu'ici absents aussi du + * calcul officiel du solde (cf. correctif fraisPerPlat du même jour dans dashboard*.js). + * - depot / retrait : dépôts/retraits manuels (table depots_retraits) enregistrés alors que + * le workspace PE était actif — AJOUTÉ le 19/09/26 suite au signalement Olivier sur Fundora, + * où 4 dépôts manuels de 800 € (un par deal PE) étaient invisibles du porte-monnaie + * crowdlending, expliquant l'essentiel du solde indicatif faussé (bien plus que les 3,45 € + * de distribution initialement en cause). Filtrées sur workspace_id != workspace appelant + * (résolu comme à l'écriture, cf. resolveActiveWorkspaceId) plutôt que sur un id "PE" en + * dur, pour rester correct si un 3e workspace apparaît un jour. `raw_source` porte la valeur + * brute de depots_retraits.source (manuel/import_excel/auto_remboursement) pour que le + * frontend puisse reconstituer le badge "AUTO" comme pour les dépôts crowdlending. + * + * corrections_solde n'a PAS de colonne workspace_id (vérifié en base) : aucune correction ne + * peut donc être "perdue" par ce bug, ce n'était pas un vrai gap malgré ce qu'un premier passage + * avait supposé par prudence. + */ +router.get('/mouvements-portefeuille', (req, res) => { + const scopeAll = req.query.scope === 'all'; + const userId = req.user.id; + + let invCond, invArgs, invCondDr, invArgsDr; + if (scopeAll) { + invCond = 'inv.user_id = ?'; + invArgs = [userId]; + invCondDr = 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invArgsDr = [userId]; + } else { + const raw = req.header('X-Investisseur-Id'); + const id = Number(raw); + if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId); + if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); + invCond = 'ipe.investisseur_id = ?'; + invArgs = [id]; + invCondDr = 'dr.investisseur_id = ?'; + invArgsDr = [id]; + } + + // Volontairement PAS de filtre workspace_id sur souscriptions/distributions/frais (tables + // dédiées PE) : ce sont par construction des lignes qui vivent hors du workspace actif + // (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée + // entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas + // dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé. + const callerWorkspaceId = resolveActiveWorkspaceId(req); + + const souscriptions = db.prepare(` + SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom, + plat_inv.nom AS plateforme_detenteur_nom, + ipe.date_souscription AS date_operation, ipe.montant_investi AS montant, + ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom + FROM investissements_pe ipe + JOIN plateformes p ON p.id = ipe.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + JOIN investisseurs inv ON inv.id = ipe.investisseur_id + WHERE ${invCond} + `).all(...invArgs); + + const distributions = db.prepare(` + SELECT r.id AS remb_id, ipe.plateforme_id, p.nom AS plateforme_nom, + plat_inv.nom AS plateforme_detenteur_nom, + r.date_remb AS date_operation, r.net_recu AS montant, + ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom + FROM remboursements r + JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id + JOIN plateformes p ON p.id = ipe.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + JOIN investisseurs inv ON inv.id = ipe.investisseur_id + WHERE r.type = 'interets_plateforme' AND ${invCond} + `).all(...invArgs); + + const frais = db.prepare(` + SELECT f.id AS frais_id, f.plateforme_id, p.nom AS plateforme_nom, + plat_inv.nom AS plateforme_detenteur_nom, + f.date_operation, f.montant, f.mode_reglement, + ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom + FROM frais_operations f + JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id + JOIN plateformes p ON p.id = f.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + JOIN investisseurs inv ON inv.id = ipe.investisseur_id + WHERE ${invCond} + `).all(...invArgs); + + const depots = db.prepare(` + SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom, + plat_inv.nom AS plateforme_detenteur_nom, + dr.date_operation, dr.type, dr.montant, dr.source AS raw_source, + dr.libelle, dr.investisseur_id, inv.nom AS investisseur_nom + FROM depots_retraits dr + JOIN plateformes p ON p.id = dr.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + JOIN investisseurs inv ON inv.id = dr.investisseur_id + WHERE dr.workspace_id != ? AND ${invCondDr} + `).all(callerWorkspaceId, ...invArgsDr); + + const rows = [ + ...souscriptions.map(r => ({ + id: `pe_sub_${r.deal_id}`, type: 'souscription', montant: r.montant, + date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, + plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, + investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, + })), + ...distributions.map(r => ({ + id: `pe_remb_${r.remb_id}`, type: 'interets_plateforme', montant: r.montant, + date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, + plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, + investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, + })), + ...frais.map(r => ({ + id: `pe_frais_${r.frais_id}`, type: 'frais', montant: r.montant, mode_reglement: r.mode_reglement, + date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, + plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, + investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, + })), + ...depots.map(r => ({ + id: `pe_dr_${r.dr_id}`, type: r.type, montant: r.montant, raw_source: r.raw_source, + date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, + plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, + investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, + })), + ]; + + res.json(rows); +}); + router.post('/', requireInvestisseur, (req, res, next) => { try { const body = Schema.parse(req.body); diff --git a/frontend/src/components/FraisFormModal.jsx b/frontend/src/components/FraisFormModal.jsx index 9113b93..b291fc1 100644 --- a/frontend/src/components/FraisFormModal.jsx +++ b/frontend/src/components/FraisFormModal.jsx @@ -33,6 +33,16 @@ import Modal from './Modal.jsx'; * InvestissementDetail.jsx, investissement déjà connu du contexte) * investissementOptions — liste pour le onFieldChange('mode_reglement', e.target.value)}> - + diff --git a/frontend/src/components/InvestissementPeFormModal.jsx b/frontend/src/components/InvestissementPeFormModal.jsx index 18e1b6e..9359166 100644 --- a/frontend/src/components/InvestissementPeFormModal.jsx +++ b/frontend/src/components/InvestissementPeFormModal.jsx @@ -218,7 +218,7 @@ function FraisBlock({ prefix, label, form, setField, handleFraisReglementChange,
diff --git a/frontend/src/components/MouvementsShared.jsx b/frontend/src/components/MouvementsShared.jsx index 2e3ce98..de6397b 100644 --- a/frontend/src/components/MouvementsShared.jsx +++ b/frontend/src/components/MouvementsShared.jsx @@ -41,7 +41,20 @@ export function signedMontant(r) { // retomber sur interets_nets — le frais doit suivre exactement la même convention, sinon la // chaîne ne retombe jamais à 0 après un retrait AUTO (confirmé par Olivier le 01/09/26 : le // reliquat observé était exactement égal au montant du frais). - if (r.type === 'frais') return r.mode_reglement === 'compte_courant' ? 0 : -Math.abs(r.montant); + // Frais PE prélevés "à la source" (19/09/26, confirmé par Olivier) : contrairement au + // crowdlending où montant_investi est le capital NET (frais 'source' = débit supplémentaire + // légitime, cf. commentaire ci-dessus), côté PE le montant_investi affiché en ligne + // "souscription" est déjà le montant BRUT transféré à la plateforme, frais compris — un + // frais 'source' PE n'est donc PAS un débit supplémentaire du porte-monnaie, sous peine de + // compter deux fois la même sortie d'argent. Affiché quand même (traçabilité), à 0 d'effet, + // comme 'compte_courant'. Repéré via r._investType === 'pe' (posé par Plateformes.jsx / + // mvNormalizedPeMouvements) plutôt que par plateforme : ne dépend pas de la plateforme, mais + // du modèle de données PE lui-même (toujours vrai pour tout deal PE). + if (r.type === 'frais') { + if (r.mode_reglement === 'compte_courant') return 0; + if (r._investType === 'pe' && r.mode_reglement === 'source') return 0; + return -Math.abs(r.montant); + } if (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant); if (r.type === 'correction') return r.montant; return Math.abs(r.montant); @@ -464,6 +477,16 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie const isFraisHorsPM = isFrais && row.mode_reglement === 'compte_courant'; const isFraisRembLinked = isFrais && row.mode_reglement === 'remboursement'; const isAuto = row.source === 'auto_remboursement'; + // Mouvement PE fusionné dans le porte-monnaie crowdlending (19/09/26, cf. Plateformes.jsx / + // mvNormalizedPeMouvements) : les liens "Voir l'investissement" / "Voir le remboursement" / + // "Modifier" ci-dessous supposent tous des ids et des routes du workspace crowdlending + // (/investissements/:id, /remboursements?edit-remb=, le modal dépôt/retrait crowdlending) — + // inapplicables tels quels à un id de la table PE ou à un dépôt enregistré sous un autre + // workspace_id. Plutôt que de risquer une navigation vers le mauvais enregistrement (voire, + // en cas de collision d'id entre investissements et investissements_pe, vers un investissement + // crowdlending sans rapport), on affiche ici une mention neutre : à modifier depuis son + // workspace d'origine. + const isForeign = row._investType === 'pe'; const typeLabel = isCorrection ? 'Correction de solde' : isBonus ? 'Bonus' @@ -638,10 +661,12 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie ? "Frais réglé via un compte courant du détenteur — n'a jamais d'effet sur le solde du porte-monnaie de la plateforme, affiché ici à titre de traçabilité uniquement." : isFraisRembLinked ? "Frais de distribution déduit du coupon brut de ce remboursement, avant calcul des prélèvements — réduit d'autant le montant net reçu (cf. avis d'opéré de la plateforme)." - : row.mode_reglement === 'source' - ? "Frais prélevé à la source par la plateforme sur le montant transféré — sort du porte-monnaie en plus du capital net déjà déduit de la souscription." - : "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."} - {' '}{isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."} + : (isForeign && row.mode_reglement === 'source') + ? "Frais prélevé à la source par la plateforme sur le montant transféré lors de la souscription au deal — déjà inclus dans le montant de la souscription, sans effet supplémentaire sur le solde du porte-monnaie." + : row.mode_reglement === 'source' + ? "Frais prélevé à la source par la plateforme sur le montant transféré — sort du porte-monnaie en plus du capital net déjà déduit de la souscription." + : "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."} + {' '}{isForeign ? null : isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
)} @@ -656,7 +681,11 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
- {isAuto ? null : (isRemb || isPrelevement) ? ( + {isForeign ? ( +
+ Mouvement Private Equity — modifiable depuis le workspace Private Equity. +
+ ) : isAuto ? null : (isRemb || isPrelevement) ? ( diff --git a/frontend/src/pages/FraisPe.jsx b/frontend/src/pages/FraisPe.jsx index 8d9aaba..fd2cd4a 100644 --- a/frontend/src/pages/FraisPe.jsx +++ b/frontend/src/pages/FraisPe.jsx @@ -559,6 +559,7 @@ export default function FraisPe() { investissementOptions={investissementOptions} confirmingDelete={confirmingDelete} setConfirmingDelete={setConfirmingDelete} + pe /> ); diff --git a/frontend/src/pages/InvestissementPeDetail.jsx b/frontend/src/pages/InvestissementPeDetail.jsx index bb47f8e..540e1c1 100644 --- a/frontend/src/pages/InvestissementPeDetail.jsx +++ b/frontend/src/pages/InvestissementPeDetail.jsx @@ -1015,6 +1015,7 @@ export default function InvestissementPeDetail() { submitting={fraisSubmitting} comptesInvestisseur={comptesInvestisseur} locked + pe investissementOptions={[]} confirmingDelete={fraisConfirmingDelete} setConfirmingDelete={setFraisConfirmingDelete} diff --git a/frontend/src/pages/Plateformes.jsx b/frontend/src/pages/Plateformes.jsx index e0680a6..72de7e9 100644 --- a/frontend/src/pages/Plateformes.jsx +++ b/frontend/src/pages/Plateformes.jsx @@ -306,6 +306,12 @@ export default function Plateformes() { const [allDepots, setAllDepots] = useState([]); const [allCorrections, setAllCorrections] = useState([]); const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via mvInvById) + // Mouvements porte-monnaie PE (souscriptions/distributions/frais) vivant hors du workspace + // crowdlending actif — cf. GET /investissements-pe/mouvements-portefeuille. Une plateforme + // (ex. Fundora) peut faire du crowdlending ET du PE avec un porte-monnaie physique unique ; + // sans ça, le solde indicatif de l'onglet "Mouvements Porte-monnaie" ignorait tout l'argent + // côté PE (bug signalé par Olivier le 19/09/26). + const [allPeWalletMouvements, setAllPeWalletMouvements] = useState([]); 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 @@ -377,7 +383,7 @@ export default function Plateformes() { setLoading(true); try { const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; - const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps] = await Promise.all([ + const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps, peWalletMouvements] = await Promise.all([ api.get('/investissements', scopeParams), api.get('/plateformes'), api.get('/remboursements', scopeParams), @@ -389,6 +395,10 @@ export default function Plateformes() { // /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(() => []), + // Toujours hors scopeParams (scope=all/X-Investisseur-Id géré côté backend comme les + // autres endpoints ci-dessus) mais jamais borné au workspace actif, volontairement — + // cf. commentaire de la route. + api.get('/investissements-pe/mouvements-portefeuille', scopeParams).catch(() => []), ]); setAllRows(rows); setPlats(p); @@ -399,6 +409,7 @@ export default function Plateformes() { setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []); setPfuRates(Array.isArray(pfu) ? pfu : []); setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []); + setAllPeWalletMouvements(Array.isArray(peWalletMouvements) ? peWalletMouvements : []); } finally { setLoading(false); setDrillRefreshKey(k => k + 1); } }; @@ -1078,7 +1089,7 @@ export default function Plateformes() { allReinvests, allCorrections, plats). Toute évolution de la logique de décomposition des mouvements doit être répercutée dans les deux fichiers. ══════════════════════════════════════════════════════════════════════ */ - const [mvFilter, setMvFilter] = useState({ type: '', plateforme_ids: [], projet: '', year: '', month: '' }); + const [mvFilter, setMvFilter] = useState({ type: '', investType: '', plateforme_ids: [], projet: '', year: '', month: '' }); const [mvListFocused, setMvListFocused] = useState(false); const [mvModalOpen, setMvModalOpen] = useState(false); const [mvEditingId, setMvEditingId] = useState(null); @@ -1327,6 +1338,32 @@ export default function Plateformes() { return true; }), [mvNormalizedFraisMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); + // Mouvements PE (19/09/26, demande Olivier — porte-monnaie unifié) : déjà mis en forme côté + // backend (GET /investissements-pe/mouvements-portefeuille, type/montant/date_operation/ + // plateforme_id/libelle prêts à l'emploi), on ajoute juste ici _investType (pour le filtre et + // le badge) et `source` (même convention que les lignes crowdlending, pour l'export). + // Depuis le 19/09/26, allPeWalletMouvements inclut aussi des lignes type='depot'/'retrait' + // (dépôts manuels enregistrés côté workspace PE, ex. les 4 x 800 € de Fundora) : on leur + // reconstitue un `source` cohérent avec les dépôts crowdlending (mvRows, via raw_source = + // valeur brute de depots_retraits.source) pour que le badge "AUTO" et l'export CSV/XLS + // restent corrects. + const mvNormalizedPeMouvements = useMemo(() => allPeWalletMouvements.map(r => ({ + ...r, + _investType: 'pe', + source: r.type === 'souscription' ? 'souscription' + : r.type === 'frais' ? 'frais_operation' + : (r.type === 'depot' || r.type === 'retrait') ? (r.raw_source || 'manuel') + : 'remboursement', + })), [allPeWalletMouvements]); + + const mvFilteredPeMouvements = useMemo(() => mvNormalizedPeMouvements.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; + }), [mvNormalizedPeMouvements, 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 @@ -1338,46 +1375,55 @@ export default function Plateformes() { 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 r of mvNormalizedPeMouvements) 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, mvNormalizedFraisMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]); + }, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, mvNormalizedPeMouvements, 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/ - // mvNormalizedSouscriptionMouvements), pas sur les variantes déjà filtrées année/mois/ - // type/projet — sinon le cumul démarrerait arbitrairement au milieu de l'historique. + // mvNormalizedSouscriptionMouvements/mvNormalizedFraisMouvements/mvNormalizedPeMouvements), + // pas sur les variantes déjà filtrées année/mois/type/projet/investType — sinon le cumul + // démarrerait arbitrairement au milieu de l'historique. mvNormalizedPeMouvements est inclus + // ici (19/09/26, demande Olivier) même s'il n'a pas d'onglet dédié pour l'afficher seul : sur + // une plateforme partagée, ces lignes PE font partie du MÊME porte-monnaie physique, donc du + // même cumul, qu'on les affiche ou non selon le filtre "Type d'investissement" du moment. // Purement indicatif (affichage/export) — ne remplace pas computeSoldePortefeuille. - // Voir [[project_mouvements_solde_indicatif]]. const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([ ...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements, - ...mvNormalizedFraisMouvements, - ]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements]); + ...mvNormalizedFraisMouvements, ...mvNormalizedPeMouvements, + ]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, mvNormalizedPeMouvements]); const mvTableRows = useMemo(() => { const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait'; const showCorr = !mvFilter.type || mvFilter.type === 'correction'; - const drPart = showDR - ? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '' })) + const showCl = mvFilter.investType !== 'pe'; + const showPe = mvFilter.investType !== 'crowdlending'; + const drPart = showDR && showCl + ? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '', _investType: 'crowdlending' })) : []; - const corrPart = showCorr ? mvFilteredCorrections.map(c => ({ ...c, _projet: '' })) : []; - const rembPart = mvFilteredRembMouvements + const corrPart = showCorr && showCl ? mvFilteredCorrections.map(c => ({ ...c, _projet: '', _investType: 'crowdlending' })) : []; + const rembPart = showCl ? mvFilteredRembMouvements .filter(r => !mvFilter.type || r.type === mvFilter.type) - .map(r => ({ ...r, _projet: r.libelle || '' })); - const subPart = mvFilteredSouscriptionMouvements + .map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : []; + const subPart = showCl ? mvFilteredSouscriptionMouvements .filter(r => !mvFilter.type || r.type === mvFilter.type) - .map(r => ({ ...r, _projet: r.libelle || '' })); - const fraisPart = mvFilteredFraisMouvements + .map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : []; + const fraisPart = showCl ? mvFilteredFraisMouvements .filter(r => !mvFilter.type || r.type === mvFilter.type) - .map(r => ({ ...r, _projet: r.libelle || '' })); + .map(r => ({ ...r, _projet: r.libelle || '', _investType: 'crowdlending' })) : []; + const pePart = showPe ? mvFilteredPeMouvements + .filter(r => !mvFilter.type || r.type === mvFilter.type) + .map(r => ({ ...r, _projet: r.libelle || '' })) : []; return sortMouvements( - [...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart] + [...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart, ...pePart] .filter(r => !mvFilter.projet || r._projet === mvFilter.projet) ).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) })); - }, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]); + }, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilteredPeMouvements, mvFilter.type, mvFilter.projet, mvFilter.investType, mvRembProjetById, mvSoldeIndicatifByRowId]); const { pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage, @@ -2759,6 +2805,14 @@ export default function Plateformes() {
+
+ + +
{isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'} - {r._projet || '—'} + {r._investType === 'pe' && PE}{r._projet || '—'} + {fmtEUR(r.montant)} @@ -2896,7 +2950,7 @@ export default function Plateformes() { color, }}>{label} - {r._projet || '—'} + {r._investType === 'pe' && PE}{r._projet || '—'} − {fmtEUR(r.montant)} @@ -2933,7 +2987,7 @@ export default function Plateformes() { }}>RÉINVEST. )} - {r._projet || '—'} + {r._investType === 'pe' && PE}{r._projet || '—'} − {fmtEUR(r.montant)} @@ -2945,11 +2999,17 @@ export default function Plateformes() { if (r.type === 'frais') { const horsPM = r.mode_reglement === 'compte_courant'; const rembLinked = r.mode_reglement === 'remboursement'; + // Frais PE "à la source" (19/09/26) : déjà inclus dans le montant de la + // souscription (-800 € par ex.) — pas un débit supplémentaire du + // porte-monnaie. Même traitement visuel que HORS PM/DÉDUIT REMB. (grisé, + // badge, 0 d'effet sur le solde indicatif via signedMontant). + const dejaInclusSouscr = r._investType === 'pe' && r.mode_reglement === 'source'; + const zeroImpact = horsPM || dejaInclusSouscr; return ( setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} - style={{ cursor: 'pointer', opacity: horsPM ? 0.75 : 1 }}> + style={{ cursor: 'pointer', opacity: zeroImpact ? 0.75 : 1 }}> {fmtDate(r.date_operation)} {r.plateforme_nom} {multiDetenteur && {r.plateforme_detenteur_nom || '—'}} @@ -2981,9 +3041,19 @@ export default function Plateformes() { 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. )} + {dejaInclusSouscr && ( + DÉDUIT SOUSCR. + )} - {r._projet || '—'} - + {r._investType === 'pe' && PE}{r._projet || '—'} + − {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'} @@ -2992,6 +3062,12 @@ export default function Plateformes() { ); } const isAuto = r.source === 'auto_remboursement'; + // Dépôt/retrait PE (19/09/26) : même rendu que les dépôts crowdlending, + // mais SANS le menu d'édition/suppression — cette ligne vit dans la table + // depots_retraits sous le workspace PE, et l'éditer/la supprimer depuis ce + // menu (pensé pour le workspace crowdlending) réattribuerait sa gestion au + // mauvais endroit. Modifiable uniquement depuis le workspace PE d'origine. + const isForeign = r._investType === 'pe'; return ( AUTO )} - {r._projet || '—'} + {isForeign && PE}{r._projet || '—'} {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'} e.stopPropagation()}> - {!isAuto && ( + {!isAuto && !isForeign && (