From 4cd4cbce713fa2a764c8f0efd1457b1aa2e14349 Mon Sep 17 00:00:00 2001 From: Olivier Date: Fri, 18 Sep 2026 08:55:38 +0200 Subject: [PATCH] Fix --- backend/src/db/index.js | 33 +++++++++++++++++++ backend/src/routes/investissementsPe.js | 31 ++++++++++++----- frontend/src/pages/InvestissementPeDetail.jsx | 19 +++++++++-- frontend/src/pages/InvestissementsPe.jsx | 8 +++++ 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 1c923a1..9e4795c 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -3360,6 +3360,39 @@ if (!invPeValCols.includes('plus_value_latente')) { db.exec('ALTER TABLE investissements_pe_valorisations ADD COLUMN plus_value_latente REAL'); } +// Migration : capital investissable (18/09/26, demande Olivier — bug remonté : le "capital non +// déployé" d'une valorisation détaillée était calculé sur montant_investi (le montant souscrit +// BRUT), alors qu'une partie a pu être immédiatement prélevée en frais "à la source" à la +// souscription (cf. syncValorisationInitiale, InvestissementsPe.jsx) — le capital réellement +// disponible à déployer est montant_investi − ces frais, jamais le montant brut. Colonne +// nullable, calculée et fournie par le front à chaque sauvegarde du deal (POST/PUT +// /investissements-pe) à partir des frais "à la source" saisis à l'étape "Frais" de la modale ; +// NULL = deal sans frais à la source connus, auquel cas montant_investi fait référence partout +// où capital_investissable est utilisé (cf. investissementsPe.js). +const invPeCols = db.prepare('PRAGMA table_info(investissements_pe)').all().map(c => c.name); +if (!invPeCols.includes('capital_investissable')) { + db.exec('ALTER TABLE investissements_pe ADD COLUMN capital_investissable REAL'); + // Backfill : pour tout deal ayant déjà une "Valorisation initiale" auto-générée (créée par + // syncValorisationInitiale AVANT cette migration, donc déjà nette des frais à la source), son + // montant EST la définition de capital_investissable — évite d'obliger Olivier à rouvrir et + // ré-enregistrer chaque deal existant pour que le calcul se corrige. Les libellés doivent + // rester synchronisés avec AUTO_VAL_NOTE / AUTO_VAL_NOTE_LEGACY (InvestissementsPe.jsx). + db.exec(` + UPDATE investissements_pe + SET capital_investissable = ( + SELECT v.valorisation_nette FROM investissements_pe_valorisations v + WHERE v.investissement_pe_id = investissements_pe.id + AND v.notes IN ('Valorisation initiale (souscription)', 'Valorisation initiale (frais de souscription)') + ORDER BY v.date_valorisation ASC, v.id ASC LIMIT 1 + ) + WHERE EXISTS ( + SELECT 1 FROM investissements_pe_valorisations v + WHERE v.investissement_pe_id = investissements_pe.id + AND v.notes IN ('Valorisation initiale (souscription)', 'Valorisation initiale (frais de souscription)') + ) + `); +} + // 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 b01efac..8d19f1d 100644 --- a/backend/src/routes/investissementsPe.js +++ b/backend/src/routes/investissementsPe.js @@ -29,6 +29,13 @@ const Schema = z.object({ date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), duree_mois: z.number().int().positive().nullable().optional(), montant_investi: z.number().positive(), + // Capital réellement disponible à déployer, net des frais prélevés "à la source" à la + // souscription (18/09/26, demande Olivier — cf. migration capital_investissable dans + // db/index.js). Calculé et fourni par le front (InvestissementsPe.jsx / InvestissementPeDetail + // .jsx) à partir de l'étape "Frais" de la modale ; NULL si inconnu (deal sans frais à la + // source, ou pas encore resauvegardé depuis cette fonctionnalité) — auquel cas montant_investi + // sert de repli partout où capital_investissable est utilisé. + capital_investissable: z.number().nonnegative().nullable().optional(), objectif: z.string().optional(), statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'), reference: z.string().optional(), @@ -170,11 +177,12 @@ router.post('/', requireInvestisseur, (req, res, next) => { const r = db.prepare(` INSERT INTO investissements_pe (investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription, - duree_mois, montant_investi, objectif, statut, reference, notes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null, - body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.objectif || null, + body.date_souscription, body.duree_mois ?? null, body.montant_investi, + body.capital_investissable ?? null, body.objectif || null, body.statut, body.reference || null, body.notes || null, ); res.status(201).json({ id: r.lastInsertRowid, ...body }); @@ -227,13 +235,13 @@ router.put('/:id', requireInvestisseur, (req, res, next) => { db.prepare(` UPDATE investissements_pe SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?, - duree_mois=?, montant_investi=?, objectif=?, statut=?, reference=?, notes=?, - updated_at=datetime('now') + duree_mois=?, montant_investi=?, capital_investissable=?, objectif=?, statut=?, + reference=?, notes=?, updated_at=datetime('now') WHERE id=? `).run( investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription, - body.duree_mois ?? null, body.montant_investi, body.objectif || null, body.statut, - body.reference || null, body.notes || null, id, + body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null, + body.objectif || null, body.statut, body.reference || null, body.notes || null, id, ); res.json({ id, ...body }); } catch (e) { next(e); } @@ -275,16 +283,21 @@ router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => { // 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). + // capital_non_deploye (déduit = capital investissable du deal − capital_deploye, jamais + // négatif). Capital investissable = montant_investi net des frais "à la source" prélevés à + // la souscription quand ils sont connus (deal.capital_investissable), sinon montant_investi + // brut par repli (18/09/26, correctif Olivier — jusque-là on utilisait toujours le montant + // brut, ce qui gonflait à tort le capital non déployé pour un deal avec frais à la source). // 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) { + const capitalInvestissable = deal.capital_investissable ?? deal.montant_investi; capitalDeploye = body.capital_deploye; plusValueLatente = body.plus_value_latente ?? 0; - const capitalNonDeploye = Math.max(0, deal.montant_investi - capitalDeploye); + const capitalNonDeploye = Math.max(0, capitalInvestissable - capitalDeploye); valorisationNette = Math.round((capitalDeploye + plusValueLatente + capitalNonDeploye) * 100) / 100; } diff --git a/frontend/src/pages/InvestissementPeDetail.jsx b/frontend/src/pages/InvestissementPeDetail.jsx index 60440b0..9512757 100644 --- a/frontend/src/pages/InvestissementPeDetail.jsx +++ b/frontend/src/pages/InvestissementPeDetail.jsx @@ -204,6 +204,10 @@ export default function InvestissementPeDetail() { (hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) + (hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0); + // Capital réellement disponible à déployer = montant investi net des frais prélevés "à la + // source" — même correctif que InvestissementsPe.jsx::submit (18/09/26, Olivier). + const capitalInvestissable = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100); + const payload = { plateforme_id: Number(form.plateforme_id), investisseur_id: Number(form.investisseur_id) || undefined, @@ -212,6 +216,7 @@ export default function InvestissementPeDetail() { date_souscription: form.date_souscription, duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined, montant_investi: Number(form.montant_investi), + capital_investissable: capitalInvestissable, objectif: form.objectif || undefined, statut: form.statut, reference: form.reference || undefined, @@ -250,8 +255,11 @@ export default function InvestissementPeDetail() { const hasBreakdown = valForm.capital_deploye !== ''; const capitalDeploye = hasBreakdown ? (Number(valForm.capital_deploye) || 0) : undefined; const plusValueLatente = hasBreakdown ? (Number(valForm.plus_value_latente) || 0) : undefined; + // Capital investissable (net des frais "à la source"), pas le montant investi brut — + // correctif 18/09/26, cf. deal.capital_investissable. + const capitalInvestissable = Number(deal.capital_investissable ?? deal.montant_investi); const valorisationNette = hasBreakdown - ? Math.round((capitalDeploye + plusValueLatente + Math.max(0, Number(deal.montant_investi) - capitalDeploye)) * 100) / 100 + ? Math.round((capitalDeploye + plusValueLatente + Math.max(0, capitalInvestissable - capitalDeploye)) * 100) / 100 : Number(valForm.valorisation_nette); await api.post(`/investissements-pe/${id}/valorisations`, { date_valorisation: valForm.date_valorisation, @@ -402,7 +410,7 @@ export default function InvestissementPeDetail() { 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; + ? Math.max(0, Number(deal.capital_investissable ?? deal.montant_investi) - valFormCapitalDeploye) : 0; const valFormComputedNette = valFormHasBreakdown ? Math.round((valFormCapitalDeploye + valFormPlusValue + valFormCapitalNonDeploye) * 100) / 100 : null; @@ -441,7 +449,12 @@ export default function InvestissementPeDetail() {
Montant investi
-
{fmtEUR(deal.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
diff --git a/frontend/src/pages/InvestissementsPe.jsx b/frontend/src/pages/InvestissementsPe.jsx index 777810d..20f06c5 100644 --- a/frontend/src/pages/InvestissementsPe.jsx +++ b/frontend/src/pages/InvestissementsPe.jsx @@ -404,6 +404,13 @@ export default function InvestissementsPe() { (hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) + (hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0); + // Capital réellement disponible à déployer = montant investi net des frais prélevés "à la + // source" (correctif Olivier 18/09/26 : le calcul de "capital non déployé" d'une valorisation + // détaillée utilisait à tort le montant investi BRUT). Même montant que celui utilisé pour la + // "Valorisation initiale" auto-générée ci-dessus (syncValorisationInitiale) — persisté ici + // sur le deal lui-même pour que le backend n'ait pas à le redéduire à chaque valorisation. + const capitalInvestissable = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100); + const payload = { plateforme_id: Number(form.plateforme_id), investisseur_id: Number(form.investisseur_id) || undefined, @@ -412,6 +419,7 @@ export default function InvestissementsPe() { date_souscription: form.date_souscription, duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined, montant_investi: Number(form.montant_investi), + capital_investissable: capitalInvestissable, objectif: form.objectif || undefined, statut: form.statut, reference: form.reference || undefined,