This commit is contained in:
ocroguennec committed 2026-09-19 14:37:02 +02:00
1 parent 6bba34396c
commit 1d5737703b
9 files changed
+344 -42

No files matched your search

+27 -7
View File
@@ -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);
+14 -3
View File
@@ -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] : []));
+153
View File
@@ -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);
+12 -1
View File
@@ -33,6 +33,16 @@ import Modal from './Modal.jsx';
* InvestissementDetail.jsx, investissement déjà connu du contexte)
* investissementOptions — liste pour le <select> investissement (mode non-locked
* uniquement) — { id, plateforme_nom, nom_projet }[]
* pe — bool (19/09/26, demande Olivier) : true quand ce frais
* est rattaché à un deal Private Equity plutôt qu'à un
* investissement crowdlending. Ne change que le libellé de
* l'option "source" du sélecteur Prélèvement ci-dessous —
* côté PE, ce mode réduit le capital investissable du deal
* (montant_investi reste le montant BRUT transféré, cf.
* InvestissementPeFormModal.jsx / capital_investissable),
* alors que côté crowdlending il s'ajoute au capital net
* déjà déduit à la souscription — deux mécaniques
* différentes qui méritaient des libellés différents.
* confirmingDelete, setConfirmingDelete — état de confirmation de suppression (géré par le
* parent, remis à false dans son propre onClose)
*/
@@ -45,6 +55,7 @@ export default function FraisFormModal({
investissementOptions = [],
confirmingDelete, setConfirmingDelete,
submitting = false,
pe = false,
}) {
const modalTitle = editingId ? 'Modifier le frais' : 'Ajouter un frais';
@@ -118,7 +129,7 @@ export default function FraisFormModal({
<label>Prélèvement</label>
<select value={form.mode_reglement}
onChange={e => onFieldChange('mode_reglement', e.target.value)}>
<option value="source">À la source (sans mouvement complémentaire)</option>
<option value="source">{pe ? 'Lors de la souscription (diminue le montant investissable)' : 'À 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>
@@ -218,7 +218,7 @@ function FraisBlock({ prefix, label, form, setField, handleFraisReglementChange,
<div style={{ gridColumn: '1 / -1' }}>
<label>Prélèvement</label>
<select value={modeReglement} onChange={e => handleFraisReglementChange(prefix, e.target.value)}>
<option value="source">À la source (sans mouvement complémentaire)</option>
<option value="source">Lors de la souscription (diminue le montant investissable)</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>
+32 -3
View File
@@ -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)."
: (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."}
{' '}{isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
{' '}{isForeign ? null : isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
</span>
</div>
)}
@@ -656,7 +681,11 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
</div>
<div className="dr-detail-footer">
{isAuto ? null : (isRemb || isPrelevement) ? (
{isForeign ? (
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', fontStyle: 'italic', padding: '4px 2px' }}>
Mouvement Private Equity — modifiable depuis le workspace Private Equity.
</div>
) : isAuto ? null : (isRemb || isPrelevement) ? (
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
Voir / modifier le remboursement
</button>
+1
View File
@@ -559,6 +559,7 @@ export default function FraisPe() {
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
pe
/>
</>
);
@@ -1015,6 +1015,7 @@ export default function InvestissementPeDetail() {
submitting={fraisSubmitting}
comptesInvestisseur={comptesInvestisseur}
locked
pe
investissementOptions={[]}
confirmingDelete={fraisConfirmingDelete}
setConfirmingDelete={setFraisConfirmingDelete}
+103 -27
View File
@@ -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() {
<option value="prelevement_local">Prélèvements étranger</option>
</select>
</div>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Type d'investissement</label>
<select value={mvFilter.investType} onChange={e => setMvFilter(f => ({ ...f, investType: e.target.value }))}>
<option value="">Tous</option>
<option value="crowdlending">Crowdlending</option>
<option value="pe">Private Equity</option>
</select>
</div>
<div style={{ flex: 2, minWidth: 160 }}>
<label>Plateforme</label>
<div style={{
@@ -2866,7 +2920,7 @@ export default function Plateformes() {
color: (isBonus || isCashback) ? 'var(--warning)' : isInteretsPlateforme ? 'var(--primary)' : 'var(--success)',
}}>{isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num success">
+ {fmtEUR(r.montant)}
</td>
@@ -2896,7 +2950,7 @@ export default function Plateformes() {
color,
}}>{label}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
@@ -2933,7 +2987,7 @@ export default function Plateformes() {
}}>RÉINVEST.</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
@@ -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 (
<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 }}>
style={{ cursor: 'pointer', opacity: zeroImpact ? 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>}
@@ -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.</span>
)}
{dejaInclusSouscr && (
<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à inclus dans le montant de la souscription — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT SOUSCR.</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className={horsPM ? 'num text-muted' : 'num danger'}>
<td className="text-muted">{r._investType === 'pe' && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className={zeroImpact ? 'num text-muted' : 'num danger'}>
− {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
@@ -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 (
<tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
@@ -3013,14 +3089,14 @@ export default function Plateformes() {
}}>AUTO</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="text-muted">{isForeign && <span style={{ display: 'inline-block', fontSize: 10, fontWeight: 700, letterSpacing: '0.03em', padding: '1px 5px', borderRadius: 4, marginRight: 6, background: 'color-mix(in srgb, var(--primary) 15%, transparent)', color: 'var(--primary)' }} title="Mouvement Private Equity">PE</span>}{r._projet || '—'}</td>
<td className={`num ${r.type === 'depot' ? 'success' : 'danger'}`}
style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : '−'} {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' }} onClick={e => e.stopPropagation()}>
{!isAuto && (
{!isAuto && !isForeign && (
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => mvOpenRowMenu(e, r)}>⋮</button>