diff --git a/backend/src/routes/v1/dashboard.js b/backend/src/routes/v1/dashboard.js index 21d4852..49ca787 100644 --- a/backend/src/routes/v1/dashboard.js +++ b/backend/src/routes/v1/dashboard.js @@ -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, }); }); diff --git a/backend/src/routes/v1/fraisOperations.js b/backend/src/routes/v1/fraisOperations.js index 46e5a97..9ea4c78 100644 --- a/backend/src/routes/v1/fraisOperations.js +++ b/backend/src/routes/v1/fraisOperations.js @@ -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=` 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); }); diff --git a/backend/src/routes/v1/index.js b/backend/src/routes/v1/index.js index a3a97b4..40826b6 100644 --- a/backend/src/routes/v1/index.js +++ b/backend/src/routes/v1/index.js @@ -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); diff --git a/backend/src/routes/v1/investissementsPe.js b/backend/src/routes/v1/investissementsPe.js new file mode 100644 index 0000000..c126b38 --- /dev/null +++ b/backend/src/routes/v1/investissementsPe.js @@ -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; diff --git a/backend/src/swagger.js b/backend/src/swagger.js index c5c4674..a8afd60 100644 --- a/backend/src/swagger.js +++ b/backend/src/swagger.js @@ -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.", }, diff --git a/mcp-server/README.md b/mcp-server/README.md index ddbec56..ba707ca 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -1,10 +1,11 @@ # crowdlending-mcp-server -Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de -crowdlending. Il expose en lecture seule les données d'un investisseur -(investissements, remboursements, dépôts/retraits, dashboard) à un client -MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en -s'appuyant sur l'API publique `/api/v1` du backend. +Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille — +crowdlending et Private Equity. Il expose en lecture seule les données d'un +investisseur (investissements crowdlending et PE, remboursements, +dépôts/retraits, frais, dashboard) à un client MCP — Claude Desktop, Claude +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 l'app web. diff --git a/mcp-server/tools.js b/mcp-server/tools.js index f3320b0..37ce9f4 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -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 {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, " + "reste valable même après un retrait complet des plateformes). Les " + "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."), inputSchema: { 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( 'crowdlending_list_remboursements', { @@ -196,17 +253,25 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo "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 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: { date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() .describe('Date de début au format YYYY-MM-DD (incluse)'), date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() .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, }, - async ({ date_debut, date_fin }) => { - try { return toolResult(await apiGet('/frais-operations', { date_debut, date_fin })); } + async ({ date_debut, date_fin, workspace }) => { + try { return toolResult(await apiGet('/frais-operations', { date_debut, date_fin, workspace })); } catch (e) { return toolError(e); } }, );