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 —
|
* `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
|
* reste valable même après un retrait complet des plateformes, contrairement à une approche
|
||||||
* basée sur le solde courant.
|
* 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]
|
* tags: [Dashboard]
|
||||||
* security: [{ ApiKeyAuth: [] }]
|
* security: [{ ApiKeyAuth: [] }]
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -149,6 +157,46 @@ router.get('/', (req, res) => {
|
|||||||
- fraisHorsRembRow.total
|
- 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({
|
res.json({
|
||||||
investissements,
|
investissements,
|
||||||
interets: { ...interets, annee: annee || null },
|
interets: { ...interets, annee: annee || null },
|
||||||
@@ -160,6 +208,7 @@ router.get('/', (req, res) => {
|
|||||||
corrections: round2(correctionsRow.total),
|
corrections: round2(correctionsRow.total),
|
||||||
frais_hors_remboursement: round2(fraisHorsRembRow.total),
|
frais_hors_remboursement: round2(fraisHorsRembRow.total),
|
||||||
},
|
},
|
||||||
|
investissements_pe,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const router = Router();
|
|||||||
* @openapi
|
* @openapi
|
||||||
* /frais-operations:
|
* /frais-operations:
|
||||||
* get:
|
* 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: >
|
* description: >
|
||||||
* Frais facturés par certaines plateformes en dehors des intérêts/prélèvements fiscaux
|
* 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
|
* 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")
|
* fois. Tous les autres modes ("source", "portefeuille", "compte_courant")
|
||||||
* représentent chacun une sortie d'argent réelle (c'est cet ensemble — tout sauf
|
* 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).
|
* "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]
|
* tags: [Frais]
|
||||||
* security: [{ ApiKeyAuth: [] }]
|
* security: [{ ApiKeyAuth: [] }]
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -30,31 +38,91 @@ const router = Router();
|
|||||||
* - in: query
|
* - in: query
|
||||||
* name: date_fin
|
* name: date_fin
|
||||||
* schema: { type: string, format: date }
|
* 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:
|
* responses:
|
||||||
* 200: { description: Liste des frais }
|
* 200: { description: Liste des frais }
|
||||||
|
* 400: { description: Slug de workspace inconnu }
|
||||||
*/
|
*/
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const { date_debut, date_fin } = req.query;
|
const { date_debut, date_fin } = req.query;
|
||||||
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
|
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
|
||||||
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
|
|
||||||
const conds = [req.investisseurScopeAll
|
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 IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||||
: 'i.investisseur_id = ?'];
|
: 'i.investisseur_id = ?'];
|
||||||
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
|
const clArgs = [invParam];
|
||||||
if (date_debut) { conds.push('f.date_operation >= ?'); args.push(date_debut); }
|
if (date_debut) { clConds.push('f.date_operation >= ?'); clArgs.push(date_debut); }
|
||||||
if (date_fin) { conds.push('f.date_operation <= ?'); args.push(date_fin); }
|
if (date_fin) { clConds.push('f.date_operation <= ?'); clArgs.push(date_fin); }
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const peConds = [req.investisseurScopeAll
|
||||||
SELECT f.id, i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
|
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||||
f.montant, f.mode_reglement, f.categorie, f.date_operation,
|
: 'ipe.investisseur_id = ?'];
|
||||||
f.remboursement_id, c.nom AS compte_nom, f.notes
|
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
|
FROM frais_operations f
|
||||||
JOIN investissements i ON i.id = f.investissement_id
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
JOIN plateformes p ON p.id = i.plateforme_id
|
JOIN plateformes p ON p.id = i.plateforme_id
|
||||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||||
WHERE ${conds.join(' AND ')}
|
WHERE ${clConds.join(' AND ')}
|
||||||
ORDER BY f.date_operation DESC, f.id DESC
|
`;
|
||||||
`).all(...args);
|
// 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);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import investisseurRouter from './investisseur.js';
|
import investisseurRouter from './investisseur.js';
|
||||||
import investissementsRouter from './investissements.js';
|
import investissementsRouter from './investissements.js';
|
||||||
|
import investissementsPeRouter from './investissementsPe.js';
|
||||||
import remboursementsRouter from './remboursements.js';
|
import remboursementsRouter from './remboursements.js';
|
||||||
import depotsRetraitsRouter from './depotsRetraits.js';
|
import depotsRetraitsRouter from './depotsRetraits.js';
|
||||||
import fraisOperationsRouter from './fraisOperations.js';
|
import fraisOperationsRouter from './fraisOperations.js';
|
||||||
@@ -12,6 +13,7 @@ const router = Router();
|
|||||||
|
|
||||||
router.use('/investisseur', investisseurRouter);
|
router.use('/investisseur', investisseurRouter);
|
||||||
router.use('/investissements', investissementsRouter);
|
router.use('/investissements', investissementsRouter);
|
||||||
|
router.use('/investissements-pe', investissementsPeRouter);
|
||||||
router.use('/remboursements', remboursementsRouter);
|
router.use('/remboursements', remboursementsRouter);
|
||||||
router.use('/depots-retraits', depotsRetraitsRouter);
|
router.use('/depots-retraits', depotsRetraitsRouter);
|
||||||
router.use('/frais-operations', fraisOperationsRouter);
|
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',
|
title: 'Crowdlending Tracker API',
|
||||||
version: 'v1',
|
version: 'v1',
|
||||||
description:
|
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. " +
|
"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.",
|
"Chaque clé est scopée à un seul investisseur.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# crowdlending-mcp-server
|
# crowdlending-mcp-server
|
||||||
|
|
||||||
Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de
|
Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille —
|
||||||
crowdlending. Il expose en lecture seule les données d'un investisseur
|
crowdlending et Private Equity. Il expose en lecture seule les données d'un
|
||||||
(investissements, remboursements, dépôts/retraits, dashboard) à un client
|
investisseur (investissements crowdlending et PE, remboursements,
|
||||||
MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en
|
dépôts/retraits, frais, dashboard) à un client MCP — Claude Desktop, Claude
|
||||||
s'appuyant sur l'API publique `/api/v1` du backend.
|
Code, ou tout autre client compatible — en s'appuyant sur l'API publique
|
||||||
|
`/api/v1` du backend.
|
||||||
|
|
||||||
Il ne fait aucune écriture : toutes les modifications restent à faire dans
|
Il ne fait aucune écriture : toutes les modifications restent à faire dans
|
||||||
l'app web.
|
l'app web.
|
||||||
|
|||||||
+70
-5
@@ -30,7 +30,7 @@ const READ_ONLY_ANNOTATIONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enregistre les 9 outils de lecture de données sur `server`.
|
* Enregistre les 11 outils de lecture de données sur `server`.
|
||||||
*
|
*
|
||||||
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
||||||
* @param {object} ctx
|
* @param {object} ctx
|
||||||
@@ -85,7 +85,13 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
"depuis le début\" de l'app, toujours un total, jamais filtré par année, " +
|
"depuis le début\" de l'app, toujours un total, jamais filtré par année, " +
|
||||||
"reste valable même après un retrait complet des plateformes). Les " +
|
"reste valable même après un retrait complet des plateformes). Les " +
|
||||||
"montants d'investissements sont des soldes actuels (photo à " +
|
"montants d'investissements sont des soldes actuels (photo à " +
|
||||||
"aujourd'hui), pas des cumuls par période. Point d'entrée idéal pour " +
|
"aujourd'hui), pas des cumuls par période. Depuis le 20/09/26, une clé " +
|
||||||
|
"investissements_pe distincte résume le portefeuille Private Equity " +
|
||||||
|
"(nb_deals, total_investi, valorisation_totale, distributions_cumulees, " +
|
||||||
|
"tvpi/dpi/rvpi) — ces chiffres ne sont PAS inclus dans les champs " +
|
||||||
|
"ci-dessus (investissements, cash, gain_net_depuis_debut), qui restent " +
|
||||||
|
"strictement crowdlending ; voir crowdlending_list_investissements_pe " +
|
||||||
|
"pour le détail par deal. Point d'entrée idéal pour " +
|
||||||
"une vue d'ensemble avant d'aller chercher le détail."),
|
"une vue d'ensemble avant d'aller chercher le détail."),
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
annee: z.number().int().optional()
|
annee: z.number().int().optional()
|
||||||
@@ -146,6 +152,57 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'crowdlending_list_investissements_pe',
|
||||||
|
{
|
||||||
|
title: withLabel('Liste des investissements Private Equity'),
|
||||||
|
description: withSource(
|
||||||
|
"Liste les investissements Private Equity (deals) du portefeuille — distinct de " +
|
||||||
|
"crowdlending_list_investissements : un deal PE n'a ni échéancier connu à l'avance ni " +
|
||||||
|
"taux d'intérêt fixe, c'est 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, dont un sous-ensemble " +
|
||||||
|
"obligation_cumulee = 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. Filtrable par statut et par " +
|
||||||
|
"workspace. Ne renvoie pas l'historique des valorisations ni le détail des distributions " +
|
||||||
|
"— utiliser crowdlending_get_investissement_pe pour le détail d'un deal."),
|
||||||
|
inputSchema: {
|
||||||
|
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive'])
|
||||||
|
.optional()
|
||||||
|
.describe("Filtrer par statut. Omettre pour lister tous les deals."),
|
||||||
|
workspace: z.string().optional()
|
||||||
|
.describe('Slug du workspace à filtrer (utile si plusieurs workspaces Private Equity existent). Absent : tous confondus.'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY_ANNOTATIONS,
|
||||||
|
},
|
||||||
|
async ({ statut, workspace }) => {
|
||||||
|
try { return toolResult(await apiGet('/investissements-pe', { statut, workspace })); }
|
||||||
|
catch (e) { return toolError(e); }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'crowdlending_get_investissement_pe',
|
||||||
|
{
|
||||||
|
title: withLabel("Détail d'un investissement Private Equity"),
|
||||||
|
description: withSource(
|
||||||
|
"Retourne le détail complet d'un deal Private Equity (identifié par son id, obtenu via " +
|
||||||
|
"crowdlending_list_investissements_pe), y compris valorisations (historique complet — " +
|
||||||
|
"la plus récente est celle utilisée pour derniere_valorisation/tvpi dans la liste), " +
|
||||||
|
"remboursements (distributions perçues : retour de capital, plus-value) et " +
|
||||||
|
"frais_operations (tous modes de règlement confondus)."),
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().int().positive().describe("Identifiant de l'investissement PE"),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY_ANNOTATIONS,
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
try { return toolResult(await apiGet(`/investissements-pe/${id}`)); }
|
||||||
|
catch (e) { return toolError(e); }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'crowdlending_list_remboursements',
|
'crowdlending_list_remboursements',
|
||||||
{
|
{
|
||||||
@@ -196,17 +253,25 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
"fois. Tous les autres modes (\"source\", \"portefeuille\", " +
|
"fois. Tous les autres modes (\"source\", \"portefeuille\", " +
|
||||||
"\"compte_courant\") représentent chacun une sortie d'argent réelle : " +
|
"\"compte_courant\") représentent chacun une sortie d'argent réelle : " +
|
||||||
"c'est cet ensemble (tout sauf \"remboursement\") qui est déduit de " +
|
"c'est cet ensemble (tout sauf \"remboursement\") qui est déduit de " +
|
||||||
"gain_net_depuis_debut sur crowdlending_get_dashboard. Filtrable par période."),
|
"gain_net_depuis_debut sur crowdlending_get_dashboard. Depuis le " +
|
||||||
|
"20/09/26, couvre aussi bien les frais crowdlending que les frais " +
|
||||||
|
"Private Equity — `source_type` (\"crowdlending\" ou \"private_equity\") " +
|
||||||
|
"indique de quel côté vient chaque ligne (une ligne crowdlending porte " +
|
||||||
|
"investissement_id/nom_projet, une ligne PE porte investissement_pe_id/" +
|
||||||
|
"nom_deal). Filtrable par période et par workspace (paramètre workspace ; " +
|
||||||
|
"sans ce paramètre, les deux univers sont renvoyés ensemble)."),
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
||||||
.describe('Date de début au format YYYY-MM-DD (incluse)'),
|
.describe('Date de début au format YYYY-MM-DD (incluse)'),
|
||||||
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
||||||
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
|
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
|
||||||
|
workspace: z.string().optional()
|
||||||
|
.describe('Slug du workspace à filtrer (ex. "crowdlending" ou "private-equity"). Absent : les deux univers confondus.'),
|
||||||
},
|
},
|
||||||
annotations: READ_ONLY_ANNOTATIONS,
|
annotations: READ_ONLY_ANNOTATIONS,
|
||||||
},
|
},
|
||||||
async ({ date_debut, date_fin }) => {
|
async ({ date_debut, date_fin, workspace }) => {
|
||||||
try { return toolResult(await apiGet('/frais-operations', { date_debut, date_fin })); }
|
try { return toolResult(await apiGet('/frais-operations', { date_debut, date_fin, workspace })); }
|
||||||
catch (e) { return toolError(e); }
|
catch (e) { return toolError(e); }
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user