From d5792fb3e2a6d5f892f3f14a9667307d1afac332 Mon Sep 17 00:00:00 2001 From: Olivier Date: Fri, 18 Sep 2026 08:45:15 +0200 Subject: [PATCH] Maj --- backend/src/db/index.js | 14 +++ backend/src/routes/investissementsPe.js | 41 +++++- frontend/src/pages/InvestissementPeDetail.jsx | 119 +++++++++++++++++- 3 files changed, 163 insertions(+), 11 deletions(-) diff --git a/backend/src/db/index.js b/backend/src/db/index.js index e8dbba6..1c923a1 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -3346,6 +3346,20 @@ db.exec(` `); db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)'); +// Migration : décomposition optionnelle de la valorisation en capital déployé / plus-value +// latente (18/09/26, demande Olivier — construire une carte de répartition façon Fundora sur la +// fiche détail PE). Capital non déployé n'est JAMAIS stocké : il se déduit à l'affichage/au +// calcul (montant_investi du deal − capital_deploye de cette valorisation). Colonnes nullables : +// une valorisation peut rester en saisie simple (un seul montant net), sans détail — c'est +// d'ailleurs le cas de toutes les valorisations existantes avant cette migration. +const invPeValCols = db.prepare('PRAGMA table_info(investissements_pe_valorisations)').all().map(c => c.name); +if (!invPeValCols.includes('capital_deploye')) { + db.exec('ALTER TABLE investissements_pe_valorisations ADD COLUMN capital_deploye REAL'); +} +if (!invPeValCols.includes('plus_value_latente')) { + db.exec('ALTER TABLE investissements_pe_valorisations ADD COLUMN plus_value_latente REAL'); +} + // Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour // de capital, plus-value) perçue sur un deal PE est saisie comme un // remboursement bonus_plateforme/interets_plateforme, comme demandé par diff --git a/backend/src/routes/investissementsPe.js b/backend/src/routes/investissementsPe.js index 7ea0418..b01efac 100644 --- a/backend/src/routes/investissementsPe.js +++ b/backend/src/routes/investissementsPe.js @@ -38,6 +38,12 @@ const Schema = z.object({ const ValorisationSchema = z.object({ date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), valorisation_nette: z.number().nonnegative(), + // Répartition optionnelle (18/09/26, demande Olivier) : quand capital_deploye est renseigné, + // la valorisation_nette envoyée par le client est recalculée côté serveur (cf. POST + // /:id/valorisations) plutôt que reprise telle quelle — seule source de vérité sur ce calcul. + // Capital non déployé n'existe pas ici : toujours déduit (montant_investi du deal − capital_deploye). + capital_deploye: z.number().nonnegative().optional(), + plus_value_latente: z.number().optional(), notes: z.string().optional(), }); @@ -264,13 +270,38 @@ router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => { router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); - ownedDeal(req, id); + const deal = ownedDeal(req, id); const body = ValorisationSchema.parse(req.body); + + // Quand la répartition est renseignée (capital_deploye présent), la valorisation nette est + // recalculée ici plutôt que reprise du body : capital_deploye + plus_value_latente + + // capital_non_deploye (déduit = montant_investi du deal − capital_deploye, jamais négatif). + // Sans répartition (mode simple, historique), comportement inchangé : le montant saisi est + // utilisé tel quel et les deux colonnes restent NULL. + let valorisationNette = body.valorisation_nette; + let capitalDeploye = null; + let plusValueLatente = null; + if (body.capital_deploye != null) { + capitalDeploye = body.capital_deploye; + plusValueLatente = body.plus_value_latente ?? 0; + const capitalNonDeploye = Math.max(0, deal.montant_investi - capitalDeploye); + valorisationNette = Math.round((capitalDeploye + plusValueLatente + capitalNonDeploye) * 100) / 100; + } + const r = db.prepare(` - INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, notes) - VALUES (?,?,?,?) - `).run(id, body.date_valorisation, body.valorisation_nette, body.notes || null); - res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, ...body }); + INSERT INTO investissements_pe_valorisations + (investissement_pe_id, date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes) + VALUES (?,?,?,?,?,?) + `).run(id, body.date_valorisation, valorisationNette, capitalDeploye, plusValueLatente, body.notes || null); + res.status(201).json({ + id: r.lastInsertRowid, + investissement_pe_id: id, + date_valorisation: body.date_valorisation, + valorisation_nette: valorisationNette, + capital_deploye: capitalDeploye, + plus_value_latente: plusValueLatente, + notes: body.notes, + }); } catch (e) { next(e); } }); diff --git a/frontend/src/pages/InvestissementPeDetail.jsx b/frontend/src/pages/InvestissementPeDetail.jsx index 84fb69e..60440b0 100644 --- a/frontend/src/pages/InvestissementPeDetail.jsx +++ b/frontend/src/pages/InvestissementPeDetail.jsx @@ -52,7 +52,7 @@ const FRAIS_MODE_LABELS = { remboursement: 'Retenu sur remboursement', }; -const emptyValForm = { date_valorisation: today(), valorisation_nette: '', notes: '' }; +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: '' }; export default function InvestissementPeDetail() { @@ -243,9 +243,21 @@ export default function InvestissementPeDetail() { const submitVal = async (e) => { e?.preventDefault?.(); setValErr(null); try { + // Répartition optionnelle (18/09/26, demande Olivier) : si Capital déployé est renseigné, + // la Valorisation nette est recalculée ici à partir des 3 composantes plutôt que reprise + // du champ (qui est alors désactivé côté UI) — recalculée aussi côté serveur, qui reste la + // source de vérité finale (cf. POST /:id/valorisations). + const hasBreakdown = valForm.capital_deploye !== ''; + const capitalDeploye = hasBreakdown ? (Number(valForm.capital_deploye) || 0) : undefined; + const plusValueLatente = hasBreakdown ? (Number(valForm.plus_value_latente) || 0) : undefined; + const valorisationNette = hasBreakdown + ? Math.round((capitalDeploye + plusValueLatente + Math.max(0, Number(deal.montant_investi) - capitalDeploye)) * 100) / 100 + : Number(valForm.valorisation_nette); await api.post(`/investissements-pe/${id}/valorisations`, { date_valorisation: valForm.date_valorisation, - valorisation_nette: Number(valForm.valorisation_nette), + valorisation_nette: valorisationNette, + capital_deploye: capitalDeploye, + plus_value_latente: plusValueLatente, notes: valForm.notes || undefined, }); setValForm(emptyValForm); @@ -381,6 +393,36 @@ export default function InvestissementPeDetail() { const totalFrais = fraisOps.reduce((s, f) => s + (f.montant || 0), 0); + // ── Valorisation : détail de répartition (18/09/26, demande Olivier) ── + // Formulaire "Ajouter une valorisation" : aperçu live de Capital non déployé / Valorisation + // nette pendant la saisie, dès que Capital déployé est renseigné (mode "détaillé" — sinon + // saisie simple d'un seul montant, comme avant). Capital non déployé n'est jamais saisi : + // toujours déduit du montant investi du deal moins le capital déployé. + const valFormHasBreakdown = valForm.capital_deploye !== ''; + const valFormCapitalDeploye = valFormHasBreakdown ? (Number(valForm.capital_deploye) || 0) : 0; + const valFormPlusValue = valFormHasBreakdown ? (Number(valForm.plus_value_latente) || 0) : 0; + const valFormCapitalNonDeploye = valFormHasBreakdown + ? Math.max(0, Number(deal.montant_investi) - valFormCapitalDeploye) : 0; + const valFormComputedNette = valFormHasBreakdown + ? Math.round((valFormCapitalDeploye + valFormPlusValue + valFormCapitalNonDeploye) * 100) / 100 + : null; + + // Carte "Valorisation nette" (remplace l'ancienne tuile "Valorisation actuelle" du bloc + // Résumé) : reprend la dernière valorisation connue (valorisations[0], déjà triée DESC par + // l'API) et n'affiche la barre de répartition que si CETTE valorisation a le détail — sinon + // retombe sur un simple montant, comme avant (cas des valorisations saisies en mode simple, ou + // de celles antérieures à cette fonctionnalité). + const latestVal = valorisations[0]; + const latestValHasBreakdown = latestVal && latestVal.capital_deploye != null; + const latestValTotal = latestVal ? latestVal.valorisation_nette : deal.montant_investi; + const latestValCapitalDeploye = latestValHasBreakdown ? latestVal.capital_deploye : 0; + const latestValPlusValue = latestValHasBreakdown ? (latestVal.plus_value_latente || 0) : 0; + const latestValCapitalNonDeploye = latestValHasBreakdown + ? Math.max(0, latestValTotal - latestValCapitalDeploye - latestValPlusValue) : 0; + const valBarTotal = latestValCapitalDeploye + Math.max(0, latestValPlusValue) + latestValCapitalNonDeploye; + const pctDeploye = valBarTotal > 0 ? (latestValCapitalDeploye / valBarTotal) * 100 : 0; + const pctPlusValue = valBarTotal > 0 ? (Math.max(0, latestValPlusValue) / valBarTotal) * 100 : 0; + return ( <>
@@ -400,8 +442,6 @@ export default function InvestissementPeDetail() {
Montant investi
{fmtEUR(deal.montant_investi)}
-
Valorisation actuelle
-
{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
TVPI
{fmtMultiple(deal.tvpi)}
Distributions cumulées
@@ -409,6 +449,46 @@ export default function InvestissementPeDetail() {
Statut
{STATUT_LABELS[deal.statut] || deal.statut}
+ + {/* 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
+
+ {fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)} +
+ {latestValHasBreakdown ? ( + <> +
+
+
= 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)} + +
+ + ) : ( +
+ {valorisations.length === 0 + ? 'Aucune valorisation enregistrée.' + : 'Pas de détail de répartition pour la dernière valorisation (saisie simple).'} +
+ )} +
+
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)} {deal.strategie ? ` · ${deal.strategie}` : ''} @@ -429,8 +509,28 @@ export default function InvestissementPeDetail() {
- setValForm({ ...valForm, valorisation_nette: e.target.value })} /> + setValForm({ ...valForm, valorisation_nette: e.target.value })} />
+
+ + setValForm({ ...valForm, capital_deploye: e.target.value })} /> +
+
+ + +
+ {valFormHasBreakdown && ( +
+ + setValForm({ ...valForm, plus_value_latente: e.target.value })} /> +
+ )}
setValForm({ ...valForm, notes: e.target.value })} /> @@ -450,7 +550,14 @@ export default function InvestissementPeDetail() { {valorisations.map(v => ( {fmtDate(v.date_valorisation)} - {fmtEUR(v.valorisation_nette)} + + {fmtEUR(v.valorisation_nette)} + {v.capital_deploye != null && ( +
+ {fmtEUR(v.capital_deploye)} déployé · {(v.plus_value_latente || 0) >= 0 ? '+' : ''}{fmtEUR(v.plus_value_latente || 0)} +
+ )} + {v.notes || '—'}