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 — * `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,
}); });
}); });
+82 -14
View File
@@ -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);
}); });
+2
View File
@@ -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);
+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;
+3 -1
View File
@@ -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.",
}, },
+6 -5
View File
@@ -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
View File
@@ -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); }
}, },
); );