MAj API pour PE

This commit is contained in:
ocroguennec committed 2026-09-19 17:34:09 +02:00
1 parent c918de1e7d
commit 8817ac693d
7 files changed
+377 -25

No files matched your search

+49
View File
@@ -24,6 +24,14 @@ const round2 = v => Math.round((v ?? 0) * 100) / 100;
* `gain_net_depuis_debut.frais_hors_remboursement`. Sur tout l'historique du portefeuille —
* reste valable même après un retrait complet des plateformes, contrairement à une approche
* basée sur le solde courant.
*
* Tous les champs ci-dessus (`investissements`, `interets`, `cash`, `gain_net_depuis_debut`)
* portent UNIQUEMENT sur le crowdlending. `investissements_pe` (20/09/26) est un objet
* séparé qui résume le portefeuille Private Equity avec ses propres formules (dernière
* valorisation connue par deal, distributions perçues, TVPI/DPI/RVPI) — mêmes calculs que
* la page Tableau de bord PE de l'application. Volontairement pas fusionné dans les champs
* crowdlending ci-dessus : les deux modèles ne sont pas comparables terme à terme (un deal
* PE n'a ni statut "en retard"/"procédure" ni capital restant dû amortissable).
* tags: [Dashboard]
* security: [{ ApiKeyAuth: [] }]
* parameters:
@@ -149,6 +157,46 @@ router.get('/', (req, res) => {
- fraisHorsRembRow.total
);
// ── Section Private Equity (20/09/26, demande Olivier — améliorer l'API/MCP pour couvrir le
// PE) : mêmes formules que les KPIs de la page Tableau de bord PE de l'app (DashboardPe.jsx,
// useMemo `totals`) — investi = somme montant_investi (tous deals, actifs et soldés) ; nav =
// dernière valorisation connue de chaque deal, ou montant_investi par repli si aucune
// valorisation saisie ; distributions = somme des distributions cumulées perçues ; tvpi/dpi/
// rvpi dérivés. Objet séparé de `investissements` ci-dessus plutôt que fusionné — voir la
// note dans la description OpenAPI au-dessus de cette route.
const invPeCond = req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?';
const invPeRows = db.prepare(`
SELECT ipe.montant_investi, ipe.statut,
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
FROM investissements_pe ipe
WHERE ${invPeCond}
`).all(invParam);
let peInvesti = 0, peNav = 0, peDistributions = 0, peActifs = 0, peSoldes = 0;
for (const r of invPeRows) {
peInvesti += r.montant_investi;
peNav += (r.derniere_valorisation ?? r.montant_investi);
peDistributions += r.distributions_cumulees;
if (r.statut === 'valide' || r.statut === 'en_attente') peActifs++; else peSoldes++;
}
const investissements_pe = {
nb_deals: invPeRows.length,
nb_actifs: peActifs,
nb_soldes: peSoldes,
total_investi: round2(peInvesti),
valorisation_totale: round2(peNav),
distributions_cumulees: round2(peDistributions),
tvpi: peInvesti > 0 ? round2((peNav + peDistributions) / peInvesti) : null,
dpi: peInvesti > 0 ? round2(peDistributions / peInvesti) : null,
rvpi: peInvesti > 0 ? round2(peNav / peInvesti) : null,
};
res.json({
investissements,
interets: { ...interets, annee: annee || null },
@@ -160,6 +208,7 @@ router.get('/', (req, res) => {
corrections: round2(correctionsRow.total),
frais_hors_remboursement: round2(fraisHorsRembRow.total),
},
investissements_pe,
});
});
+82 -14
View File
@@ -7,7 +7,7 @@ const router = Router();
* @openapi
* /frais-operations:
* get:
* summary: Liste des frais liés aux investissements (entrée, gestion, distribution)
* summary: Liste des frais liés aux investissements (crowdlending et Private Equity)
* description: >
* Frais facturés par certaines plateformes en dehors des intérêts/prélèvements fiscaux
* habituels : frais d'entrée à la souscription, frais de gestion, frais sur distribution
@@ -21,6 +21,14 @@ const router = Router();
* fois. Tous les autres modes ("source", "portefeuille", "compte_courant")
* représentent chacun une sortie d'argent réelle (c'est cet ensemble — tout sauf
* "remboursement" — qui est déduit de `gain_net_depuis_debut` sur /dashboard).
*
* Depuis le 20/09/26, couvre aussi bien les frais crowdlending que les frais Private
* Equity (auparavant absents de cette route, bien que déjà présents en base) —
* `source_type` ("crowdlending" ou "private_equity") indique de quel côté vient chaque
* ligne. Une ligne crowdlending porte `investissement_id`/`nom_projet` (et
* `investissement_pe_id`/`nom_deal` valent `null`) ; une ligne PE porte l'inverse.
* `?workspace=<slug>` permet d'isoler un seul des deux univers ; sans ce paramètre, les
* deux sont renvoyés ensemble, triés par date.
* tags: [Frais]
* security: [{ ApiKeyAuth: [] }]
* parameters:
@@ -30,31 +38,91 @@ const router = Router();
* - in: query
* name: date_fin
* schema: { type: string, format: date }
* - in: query
* name: workspace
* schema: { type: string }
* description: >
* Slug du workspace à filtrer (ex. "crowdlending" ou "private-equity"). Absent par
* défaut : renvoie les deux univers confondus — 400 si le slug est inconnu.
* responses:
* 200: { description: Liste des frais }
* 400: { description: Slug de workspace inconnu }
*/
router.get('/', (req, res) => {
const { date_debut, date_fin } = req.query;
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const conds = [req.investisseurScopeAll
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
let workspaceFilter = null;
if (req.query.workspace) {
const ws = db.prepare('SELECT id, type FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
workspaceFilter = ws;
}
const clConds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (date_debut) { conds.push('f.date_operation >= ?'); args.push(date_debut); }
if (date_fin) { conds.push('f.date_operation <= ?'); args.push(date_fin); }
const clArgs = [invParam];
if (date_debut) { clConds.push('f.date_operation >= ?'); clArgs.push(date_debut); }
if (date_fin) { clConds.push('f.date_operation <= ?'); clArgs.push(date_fin); }
const rows = db.prepare(`
SELECT f.id, i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
f.montant, f.mode_reglement, f.categorie, f.date_operation,
f.remboursement_id, c.nom AS compte_nom, f.notes
const peConds = [req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?'];
const peArgs = [invParam];
if (date_debut) { peConds.push('f.date_operation >= ?'); peArgs.push(date_debut); }
if (date_fin) { peConds.push('f.date_operation <= ?'); peArgs.push(date_fin); }
// Le côté crowdlending n'a pas besoin d'un filtre workspace_id explicite : la table
// `investissements` n'a pas de colonne workspace_id, il n'existe qu'UN SEUL workspace de type
// 'crowdlending' (singleton imposé en base, cf. db/index.js) et toute ligne crowdlending lui
// appartient donc implicitement. Côté PE, plusieurs workspaces de type 'private_equity' sont
// en revanche possibles (même contrainte) — d'où le filtre explicite ici.
if (workspaceFilter?.type === 'private_equity') {
peConds.push('ipe.workspace_id = ?');
peArgs.push(workspaceFilter.id);
}
const CL_SELECT = `
SELECT 'crowdlending' AS source_type,
f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation, f.remboursement_id,
c.nom AS compte_nom, f.notes,
i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
NULL AS investissement_pe_id, NULL AS nom_deal
FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE ${conds.join(' AND ')}
ORDER BY f.date_operation DESC, f.id DESC
`).all(...args);
WHERE ${clConds.join(' AND ')}
`;
// Même ordre de colonnes que CL_SELECT ci-dessus, position par position : UNION ALL associe
// les colonnes par POSITION (pas par nom) — un ordre différent mélangerait silencieusement les
// valeurs (ex. plateforme_nom hérité à la place de nom_deal). Repéré et corrigé lors de la
// vérification par dry-run avant livraison (20/09/26).
const PE_SELECT = `
SELECT 'private_equity' AS source_type,
f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation, f.remboursement_id,
c.nom AS compte_nom, f.notes,
NULL AS investissement_id, NULL AS nom_projet, p.nom AS plateforme_nom,
ipe.id AS investissement_pe_id, ipe.nom_deal
FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE ${peConds.join(' AND ')}
`;
let rows;
if (workspaceFilter?.type === 'crowdlending') {
rows = db.prepare(`${CL_SELECT} ORDER BY f.date_operation DESC, f.id DESC`).all(...clArgs);
} else if (workspaceFilter?.type === 'private_equity') {
rows = db.prepare(`${PE_SELECT} ORDER BY f.date_operation DESC, f.id DESC`).all(...peArgs);
} else {
rows = db.prepare(`
SELECT * FROM (${CL_SELECT} UNION ALL ${PE_SELECT})
ORDER BY date_operation DESC, id DESC
`).all(...clArgs, ...peArgs);
}
res.json(rows);
});
+2
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import investisseurRouter from './investisseur.js';
import investissementsRouter from './investissements.js';
import investissementsPeRouter from './investissementsPe.js';
import remboursementsRouter from './remboursements.js';
import depotsRetraitsRouter from './depotsRetraits.js';
import fraisOperationsRouter from './fraisOperations.js';
@@ -12,6 +13,7 @@ const router = Router();
router.use('/investisseur', investisseurRouter);
router.use('/investissements', investissementsRouter);
router.use('/investissements-pe', investissementsPeRouter);
router.use('/remboursements', remboursementsRouter);
router.use('/depots-retraits', depotsRetraitsRouter);
router.use('/frais-operations', fraisOperationsRouter);
+165
View File
@@ -0,0 +1,165 @@
import { Router } from 'express';
import db from '../../db/index.js';
import { HttpError } from '../../middleware/errorHandler.js';
const router = Router();
// Mêmes colonnes enrichies que GET /api/investissements-pe (route interne,
// investissementsPe.js) : dernière valorisation connue, distributions cumulées perçues
// (remboursements liés), et le TVPI qui en découle — même formule que la fiche détail interne
// et que DashboardPe.jsx (nav = dernière valorisation, ou montant_investi si aucune
// valorisation saisie). Colonnes et FROM/JOIN séparés (plutôt qu'un seul bloc SELECT...FROM) pour
// que la route détail puisse insérer `ipe.notes` dans la liste de colonnes SANS le faire atterrir
// après le FROM/JOIN, ce qui produirait du SQL invalide (repéré et corrigé lors de la
// vérification par dry-run avant livraison, 20/09/26).
const ENRICHED_COLUMNS = `
ipe.id, ipe.nom_deal, ipe.strategie, p.nom AS plateforme_nom,
ipe.date_souscription, ipe.duree_mois, ipe.montant_investi, ipe.capital_investissable,
ipe.objectif, ipe.statut, ipe.reference,
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
(SELECT v.date_valorisation FROM investissements_pe_valorisations v
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,
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
`;
const ENRICHED_FROM = `
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
`;
function withTvpi(row) {
return {
...row,
tvpi: row.derniere_valorisation != null
? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100
: null,
};
}
/**
* @openapi
* /investissements-pe:
* get:
* summary: Liste des investissements Private Equity de l'investisseur
* description: >
* Distinct de /investissements (crowdlending) : un deal PE n'a ni échéancier connu à
* l'avance ni taux d'intérêt fixe — un montant unique investi (`montant_investi`), valorisé
* périodiquement (`derniere_valorisation`, `derniere_valorisation_date`), avec des
* distributions perçues au fil du temps (`distributions_cumulees`, un sous-ensemble marqué
* `obligation_cumulee` quand il s'agit d'intérêts sur capital non encore déployé).
* `tvpi` (Total Value to Paid-In = (dernière valorisation + distributions cumulées) /
* montant investi) est `null` tant qu'aucune valorisation n'a été saisie pour ce deal —
* mêmes formules que la page Investissements PE / le tableau de bord PE de l'application.
* tags: [Investissements PE]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: statut
* schema: { type: string, enum: [en_attente, valide, cloture, perte_definitive] }
* - in: query
* name: workspace
* schema: { type: string }
* description: >
* Slug du workspace à filtrer (utile si plusieurs workspaces Private Equity existent).
* Absent par défaut : tous les deals PE de l'investisseur, tous workspaces confondus.
* responses:
* 200: { description: Liste des investissements PE }
* 400: { description: Slug de workspace inconnu }
*/
router.get('/', (req, res) => {
const { statut } = req.query;
const conds = [req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (statut) { conds.push('ipe.statut = ?'); args.push(statut); }
if (req.query.workspace) {
const ws = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
conds.push('ipe.workspace_id = ?');
args.push(ws.id);
}
const rows = db.prepare(`
SELECT ${ENRICHED_COLUMNS}
${ENRICHED_FROM}
WHERE ${conds.join(' AND ')}
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...args);
res.json(rows.map(withTvpi));
});
/**
* @openapi
* /investissements-pe/{id}:
* get:
* summary: Détail d'un investissement PE, avec son historique de valorisations
* description: >
* `valorisations` est l'historique complet des valorisations saisies pour ce deal (la plus
* récente de cette liste est celle utilisée pour `derniere_valorisation`/`tvpi`).
* `remboursements` liste les distributions perçues (retour de capital, plus-value —
* saisies comme des remboursements de type "intérêts plateforme", cf.
* /remboursements) ; `frais_operations` liste les frais rattachés à ce deal, quel que
* soit leur mode de règlement.
* tags: [Investissements PE]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Détail de l'investissement PE }
* 404: { description: Investissement PE introuvable }
*/
router.get('/:id', (req, res, next) => {
try {
const invCond = req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const row = db.prepare(`
SELECT ${ENRICHED_COLUMNS}, ipe.notes
${ENRICHED_FROM}
WHERE ipe.id = ? AND ${invCond}
`).get(req.params.id, invParam);
if (!row) throw new HttpError(404, 'Investissement PE introuvable');
const valorisations = db.prepare(`
SELECT id, date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes
FROM investissements_pe_valorisations
WHERE investissement_pe_id = ?
ORDER BY date_valorisation DESC, id DESC
`).all(row.id);
const remboursements = db.prepare(`
SELECT id, date_remb, capital, interets_bruts, cashback, prelev_sociaux, prelev_forfaitaire,
interets_nets, net_recu, statut, distribution_nature
FROM remboursements
WHERE investissement_pe_id = ?
ORDER BY date_remb DESC
`).all(row.id);
const fraisOperations = db.prepare(`
SELECT f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation,
f.remboursement_id, c.nom AS compte_nom, f.notes
FROM frais_operations f
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE f.investissement_pe_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(row.id);
res.json({ ...withTvpi(row), valorisations, remboursements, frais_operations: fraisOperations });
} catch (e) { next(e); }
});
export default router;