MAj API pour PE
This commit is contained in:
1 parent
c918de1e7d
commit
8817ac693d
7 files changed
+377
-25
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -21,7 +21,9 @@ const swaggerSpec = swaggerJsdoc({
|
||||
title: 'Crowdlending Tracker API',
|
||||
version: 'v1',
|
||||
description:
|
||||
"API publique en lecture seule du portefeuille de crowdlending. " +
|
||||
"API publique en lecture seule du portefeuille — crowdlending et Private Equity " +
|
||||
"(cf. /investissements pour le crowdlending, /investissements-pe pour le PE ; " +
|
||||
"/remboursements, /depots-retraits et /frais-operations couvrent les deux). " +
|
||||
"Authentification par clé API (header `X-API-Key`), générée depuis Mon compte → Clés API. " +
|
||||
"Chaque clé est scopée à un seul investisseur.",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user