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() {
{/* ── Résumé ── */} -
-
-
Montant investi
-
{fmtEUR(deal.montant_investi)}
- {deal.capital_investissable != null && deal.capital_investissable !== deal.montant_investi && ( -
- dont {fmtEUR(deal.capital_investissable)} investissables (après frais à la source) -
- )}
-
TVPI
-
{fmtMultiple(deal.tvpi)}
-
Distributions cumulées
-
{fmtEUR(deal.distributions_cumulees)}
-
Statut
-
{STATUT_LABELS[deal.statut] || deal.statut}
+
+ {deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)} + {deal.strategie ? ` · ${deal.strategie}` : ''} + {deal.objectif ? ` · Objectif : ${deal.objectif}` : ''} + {deal.reference ? ` · Réf. ${deal.reference}` : ''} + {deal.statut !== 'valide' ? ` · ${STATUT_LABELS[deal.statut] || deal.statut}` : ''} +
+ {deal.notes &&
{deal.notes}
} + + {/* 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é +
+
{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
{latestValHasBreakdown ? ( @@ -478,19 +566,14 @@ export default function InvestissementPeDetail() {
= 0 ? 'var(--success)' : 'var(--danger)' }} />
-
- - - Capital déployé {fmtEUR(latestValCapitalDeploye)} - - - = 0 ? 'var(--success)' : 'var(--danger)', marginRight: 6 }} /> - {latestValPlusValue >= 0 ? 'Plus-value latente' : 'Moins-value latente'} {fmtEUR(latestValPlusValue)} - - - - Capital non déployé {fmtEUR(latestValCapitalNonDeploye)} - +
+ + = 0 ? 'var(--success)' : 'var(--danger)'} + label={latestValPlusValue >= 0 ? 'Plus-value latente' : 'Moins-value latente'} + value={latestValPlusValue} + /> +
) : ( @@ -501,14 +584,57 @@ export default function InvestissementPeDetail() {
)}
+
-
- {deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)} - {deal.strategie ? ` · ${deal.strategie}` : ''} - {deal.objectif ? ` · Objectif : ${deal.objectif}` : ''} - {deal.reference ? ` · Réf. ${deal.reference}` : ''} +
+
+
Déploiement
+ + + +
+ +
+
Performance
+ + Capital déployé + plus-value latente + capital non déployé + {tvpiSousJacent != null && <>
TVPI du sous-jacent {fmtMultiple(tvpiSousJacent)}} + } + /> + + + + +
+ +
+
Frais
+
- {deal.notes &&
{deal.notes}
}
{/* ── Valorisations ── */} diff --git a/frontend/src/pages/RemboursementsSimple.jsx b/frontend/src/pages/RemboursementsSimple.jsx index fcb3803..a5e1ca8 100644 --- a/frontend/src/pages/RemboursementsSimple.jsx +++ b/frontend/src/pages/RemboursementsSimple.jsx @@ -21,6 +21,12 @@ const TYPE_LABELS = { interets_plateforme: 'Distribution', }; +// Nature de la distribution (18/09/26) — affichée en sous-texte sous le type dans le tableau. +const DISTRIBUTION_NATURE_LABELS = { + capital: 'Restitution de capital', + interet_non_deploye: 'Intérêt sur capital non déployé', +}; + const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc']; @@ -164,6 +170,10 @@ const emptyRembForm = { // rattacher une distribution à un investissement précis pour calculer son // TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora). investissement_pe_id: '', + // Nature de la distribution (18/09/26, demande Olivier) : distingue restitution de capital + // déployé vs. intérêt sur capital non déployé — cf. RembFormModal.jsx. Optionnel, uniquement + // pertinent pour le type "Distribution" (interets_plateforme). + distribution_nature: '', date_remb: today(), statut: 'paye', notes: '', @@ -338,6 +348,7 @@ export default function RemboursementsSimple() { bonus_plateforme_id: String(r.bonus_plateforme_id || ''), bonus_investisseur_id: String(r.bonus_investisseur_id || ''), investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '', + distribution_nature: r.distribution_nature || '', date_remb: r.date_remb, statut: r.statut, notes: r.notes || '', @@ -419,6 +430,7 @@ export default function RemboursementsSimple() { bonus_plateforme_id: Number(form.bonus_plateforme_id), bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined, investissement_pe_id: Number(form.investissement_pe_id) || undefined, + distribution_nature: (!isBonusForm && form.distribution_nature) ? form.distribution_nature : undefined, date_remb: form.date_remb, statut: form.statut, notes: form.notes || undefined, @@ -840,7 +852,14 @@ export default function RemboursementsSimple() { {fmtDate(r.date_remb)} {r.plateforme_nom} {multiDetenteur && {r.plateforme_detenteur_nom || '—'}} - {TYPE_LABELS[r.type] || r.type} + + {TYPE_LABELS[r.type] || r.type} + {r.distribution_nature && ( +
+ {DISTRIBUTION_NATURE_LABELS[r.distribution_nature] || r.distribution_nature} +
+ )} + {fmtEUR(r.net_recu)} {statutLabel(r.statut)} e.stopPropagation()}>