diff --git a/backend/src/db/index.js b/backend/src/db/index.js
index 9e4795c..26dd8ac 100644
--- a/backend/src/db/index.js
+++ b/backend/src/db/index.js
@@ -3409,4 +3409,21 @@ if (!invPeCols.includes('capital_investissable')) {
}
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissement_pe_id)');
+// Migration : nature d'une distribution PE (18/09/26, demande Olivier — distinguer, au sein des
+// distributions reçues sur un deal PE, ce qui restitue le capital déployé ('capital') de ce qui
+// est un revenu d'intérêt versé sur le capital NON déployé, placé par le fonds sur un support
+// obligataire en attendant d'être appelé ('interet_non_deploye') — reprend la ligne "Obligation"
+// des captures Fundora, jusque-là non reconstituable faute de cette distinction. Nullable et
+// sans CHECK (validé côté Zod, cf. remboursements.js) : une distribution non classée n'est ni
+// l'un ni l'autre, elle reste comptée dans "Distributions" par défaut (cf.
+// investissementsPe.js::GET / et GET /:id). Sans effet sur les remboursements crowdlending
+// (type 'normal') ni sur les bonus (parrainage/plateforme), seul le type 'interets_plateforme'
+// (rebaptisé "Distribution" côté PE) l'utilise.
+{
+ const rembCols3 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
+ if (!rembCols3.includes('distribution_nature')) {
+ db.exec('ALTER TABLE remboursements ADD COLUMN distribution_nature TEXT');
+ }
+}
+
export default db;
diff --git a/backend/src/routes/investissementsPe.js b/backend/src/routes/investissementsPe.js
index 8d19f1d..7a01fad 100644
--- a/backend/src/routes/investissementsPe.js
+++ b/backend/src/routes/investissementsPe.js
@@ -110,7 +110,14 @@ router.get('/', (req, res) => {
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
- WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
+ WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees,
+ -- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé"
+ -- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora,
+ -- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET
+ -- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
+ COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
+ WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
+ AS obligation_cumulee
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
@@ -212,7 +219,14 @@ router.get('/:id', (req, res, next) => {
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
- WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
+ WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees,
+ -- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé"
+ -- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora,
+ -- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET
+ -- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
+ COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
+ WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
+ AS obligation_cumulee
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
diff --git a/backend/src/routes/remboursements.js b/backend/src/routes/remboursements.js
index aeeac46..cf46a1d 100644
--- a/backend/src/routes/remboursements.js
+++ b/backend/src/routes/remboursements.js
@@ -20,6 +20,12 @@ const Schema = z.object({
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
// Sans effet sur le type 'normal' (crowdlending).
investissement_pe_id: z.number().int().positive().nullable().optional(),
+ // Nature de la distribution pour un deal PE (18/09/26, demande Olivier) : 'capital' = restitution
+ // du capital déployé, 'interet_non_deploye' = intérêt versé sur le capital NON déployé (support
+ // obligataire du fonds) — cf. migration distribution_nature dans db/index.js. Optionnel : une
+ // distribution non classée reste comptée dans "Distributions" (pas "Obligation") côté PE. Sans
+ // effet hors type 'interets_plateforme'.
+ distribution_nature: z.enum(['capital', 'interet_non_deploye']).nullable().optional(),
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
capital: z.number().nonnegative().default(0),
cashback: z.number().nonnegative().default(0),
@@ -337,14 +343,15 @@ router.post('/', (req, res, next) => {
INSERT INTO remboursements
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
- prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
- VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?)
+ prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id,
+ investissement_pe_id, distribution_nature)
+ VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?, ?)
`).run(
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
body.prelev_sociaux, body.prelev_forfaitaire,
interets_nets, net_recu, body.statut, body.notes || null,
- resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
+ resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null, body.distribution_nature ?? null,
);
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
}
@@ -513,12 +520,13 @@ router.put('/:id', (req, res, next) => {
UPDATE remboursements
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
- investissement_pe_id=?
+ investissement_pe_id=?, distribution_nature=?
WHERE id=?
`).run(
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
body.prelev_sociaux, body.prelev_forfaitaire,
- interets_nets, net_recu, body.statut, body.notes || null, body.investissement_pe_id ?? null, id,
+ interets_nets, net_recu, body.statut, body.notes || null, body.investissement_pe_id ?? null,
+ body.distribution_nature ?? null, id,
);
} else {
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
diff --git a/frontend/src/components/RembFormModal.jsx b/frontend/src/components/RembFormModal.jsx
index b261b81..a6d6b28 100644
--- a/frontend/src/components/RembFormModal.jsx
+++ b/frontend/src/components/RembFormModal.jsx
@@ -207,6 +207,16 @@ export default function RembFormModal({
)}
+ {!locked && isInteretsPlateforme && dealOptions.length > 0 && (
+
+
+
+
+ )}
{!locked && isBonus && (
diff --git a/frontend/src/pages/InvestissementPeDetail.jsx b/frontend/src/pages/InvestissementPeDetail.jsx
index 9512757..5ad9c6e 100644
--- a/frontend/src/pages/InvestissementPeDetail.jsx
+++ b/frontend/src/pages/InvestissementPeDetail.jsx
@@ -55,6 +55,34 @@ const FRAIS_MODE_LABELS = {
const emptyValForm = { date_valorisation: today(), valorisation_nette: '', capital_deploye: '', plus_value_latente: '', notes: '' };
const emptyFraisForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
+// ── Tableau de bord "Montant souscrit / Valorisation nette" (18/09/26, demande Olivier —
+// reprise de la disposition des captures Fundora : 2 cartes avec barre de répartition + 3
+// colonnes Déploiement/Performance/Frais). Petits composants de présentation partagés par les
+// deux cartes et les 3 colonnes ci-dessous.
+function LegendDot({ color, outline, label, value }) {
+ return (
+
+
+ {label} {fmtEUR(value)}
+
+ );
+}
+
+function StatRow({ label, value, caption, valueColor }) {
+ return (
+
+
+
{label}
+
{value}
+
+ {caption &&
{caption}
}
+
+ );
+}
+
export default function InvestissementPeDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -431,6 +459,50 @@ export default function InvestissementPeDetail() {
const pctDeploye = valBarTotal > 0 ? (latestValCapitalDeploye / valBarTotal) * 100 : 0;
const pctPlusValue = valBarTotal > 0 ? (Math.max(0, latestValPlusValue) / valBarTotal) * 100 : 0;
+ // Carte "Montant souscrit" + colonnes Déploiement/Performance/Frais (18/09/26, reprise du
+ // tableau de bord Fundora, demande Olivier — même disposition, même graphique, même tableau à
+ // 3 colonnes). Frais d'entrée/de gestion "à la source" : mêmes lignes frais_operations que
+ // celles pré-remplies dans "Modifier l'investissement" (openEditDeal ci-dessus).
+ const fraisEntreeRow = fraisOps.find(f => f.categorie === 'souscription');
+ const fraisGestionRow = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
+ const fraisEntreeMontant = fraisEntreeRow?.montant || 0;
+ const fraisGestionMontant = fraisGestionRow?.montant || 0;
+
+ const capitalInvestissable = Number(deal.capital_investissable ?? deal.montant_investi);
+ const pctCapitalAuTravail = capitalInvestissable > 0 ? (latestValCapitalDeploye / capitalInvestissable) * 100 : 0;
+
+ // TVPI "du sous-jacent" = multiple sur le seul capital déployé (contrairement au TVPI global,
+ // qui rapporte à tout le montant souscrit) — dérivé de la plus-value latente saisie, pas un
+ // champ à part : (capital déployé + plus-value latente) / capital déployé.
+ const tvpiSousJacent = latestValHasBreakdown && latestValCapitalDeploye > 0
+ ? (latestValCapitalDeploye + latestValPlusValue) / latestValCapitalDeploye
+ : null;
+
+ // "Valeur totale cible" = montant souscrit × objectif, uniquement quand l'objectif est un
+ // multiple simple ("x4,5") — objectif reste volontairement en texte libre côté deal (peut
+ // aussi être une fourchette de taux, ex. "9-12 %/an" : pas de calcul possible dans ce cas,
+ // affiche "—").
+ const objectifMultipleMatch = deal.objectif && deal.objectif.match(/^x\s*(\d+(?:[.,]\d+)?)/i);
+ const valeurTotaleCible = objectifMultipleMatch
+ ? deal.montant_investi * parseFloat(objectifMultipleMatch[1].replace(',', '.'))
+ : null;
+
+ // Barre "Montant souscrit" : mêmes 4 composantes que le camembert Fundora, mais rapportées au
+ // montant souscrit BRUT (pas au capital investissable) puisque les frais en font partie.
+ const pctFraisEntreeSouscrit = deal.montant_investi > 0 ? (fraisEntreeMontant / deal.montant_investi) * 100 : 0;
+ const pctFraisGestionSouscrit = deal.montant_investi > 0 ? (fraisGestionMontant / deal.montant_investi) * 100 : 0;
+ const pctDeployeSouscrit = deal.montant_investi > 0 ? (latestValCapitalDeploye / deal.montant_investi) * 100 : 0;
+
+ // "Obligation" (18/09/26, demande Olivier) : sous-ensemble des distributions classées "intérêt
+ // sur capital non déployé" (cf. distribution_nature dans RembFormModal.jsx), reconstituant la
+ // ligne du même nom sur les captures Fundora — calculé par le backend (obligation_cumulee,
+ // somme des remboursements de ce deal tagués ainsi), jamais une estimation/un taux fabriqué
+ // localement. "Distributions" affiché ci-dessous devient donc le reste (capital + non classé),
+ // les deux sommant toujours à distributions_cumulees (qui reste la base du calcul du TVPI,
+ // inchangée — cf. GET /:id, investissementsPe.js).
+ const obligationCumulee = deal.obligation_cumulee || 0;
+ const distributionsCapital = (deal.distributions_cumulees || 0) - obligationCumulee;
+
return (
<>
@@ -446,30 +518,46 @@ export default function InvestissementPeDetail() {
}
+
+ {/* Tableau de bord "Montant souscrit / Valorisation nette" (18/09/26, reprise de la
+ disposition des captures Fundora d'Olivier : 2 cartes avec barre de répartition, puis
+ 3 colonnes Déploiement/Performance/Frais). N'affiche les graphiques que si la
+ dernière valorisation a le détail de répartition (saisie optionnelle, formulaire
+ "Valorisations" plus bas) — sinon retombe sur un résumé minimal. */}
+
+
+
1. Montant souscrit
+
+ Engagement total, décaissé en totalité à la souscription
+
+
{fmtEUR(deal.montant_investi)}
+
+
+
+
+
+
+
+
+
+
+
- {/* Valorisation nette — remplace l'ancienne tuile "Valorisation actuelle" (18/09/26,
- demande Olivier : reprendre l'esprit des captures Fundora — répartition capital
- déployé / plus-value latente / capital non déployé). Barre affichée seulement quand
- la dernière valorisation a ce détail (saisie optionnelle, formulaire ci-dessous). */}
-
-
Valorisation nette
-
+
+
2. Valorisation nette
+
+ Capital déployé + plus-value latente + capital non déployé
+