import { Router } from 'express'; import { z } from 'zod'; import path from 'node:path'; import fs from 'node:fs'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { requireInvestisseur } from '../middleware/investisseurScope.js'; import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js'; import { createZip, sanitizeZipPart } from '../utils/zip.js'; import { docsDir } from './documents.js'; const router = Router(); /** * Investissements Private Equity — table dédiée (17/09/26, cf. * project_workspaces_transformation.md), distincte de `investissements` * (crowdlending) : un deal PE n'a ni échéancier connu à l'avance ni taux * d'intérêt fixe, c'est un montant unique investi, valorisé périodiquement * par le fonds (investissements_pe_valorisations), avec un objectif de * performance hétérogène selon les deals (texte libre plutôt que structuré). * * workspace_id est NOT NULL dès la création (table neuve, pas de legacy à * backfiller) — toutes les lignes appartiennent au workspace actif au * moment de la création, et toute lecture/écriture est bornée au workspace * actif via resolveActiveWorkspaceId (même principe que depots_retraits et * remboursements). */ const Schema = z.object({ investisseur_id: z.number().int().positive().optional(), plateforme_id: z.number().int().positive(), nom_deal: z.string().min(1), strategie: z.string().optional(), date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), duree_mois: z.number().int().positive().nullable().optional(), montant_investi: z.number().positive(), // Capital réellement disponible à déployer, net des frais prélevés "à la source" à la // souscription (18/09/26, demande Olivier — cf. migration capital_investissable dans // db/index.js). Calculé et fourni par le front (InvestissementsPe.jsx / InvestissementPeDetail // .jsx) à partir de l'étape "Frais" de la modale ; NULL si inconnu (deal sans frais à la // source, ou pas encore resauvegardé depuis cette fonctionnalité) — auquel cas montant_investi // sert de repli partout où capital_investissable est utilisé. capital_investissable: z.number().nonnegative().nullable().optional(), objectif: z.string().optional(), statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'), reference: z.string().optional(), notes: z.string().optional(), // Mode de détention (19/09/26) — cf. migration mode_detention dans db/index.js. mode_detention: z.enum(['direct', 'pea_pme', 'assurance_vie']).default('direct'), }); // Déclaration d'une perte définitive de capital sur un deal PE (20/09/26, chantier // "Fiscalité PE" — cf. migration investissement_pe_pertes dans db/index.js). Même schéma que // PerteSchema côté crowdlending (investissements.js) : montant_perte éditable (perte partielle // possible si une partie du capital a déjà été recouvrée), préfilli côté frontend mais non // recalculé ici. const PerteSchema = z.object({ date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), montant_perte: z.number().nonnegative(), motif: z.string().trim().min(1, 'Le motif est obligatoire'), }); const ValorisationSchema = z.object({ date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), valorisation_nette: z.number().nonnegative(), // Répartition optionnelle (18/09/26, demande Olivier) : quand capital_deploye est renseigné, // la valorisation_nette envoyée par le client est recalculée côté serveur (cf. POST // /:id/valorisations) plutôt que reprise telle quelle — seule source de vérité sur ce calcul. // Capital non déployé n'existe pas ici : toujours déduit (montant_investi du deal − capital_deploye). capital_deploye: z.number().nonnegative().optional(), plus_value_latente: z.number().optional(), notes: z.string().optional(), }); /** Résout l'investisseur_id : body en priorité (validé), sinon header (comme depots_retraits.js) */ function resolveInvestisseurId(req, bodyInvestisseurId) { if (!bodyInvestisseurId) return req.investisseur.id; const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') .get(bodyInvestisseurId, req.user.id); if (!row) throw new HttpError(403, 'Investisseur non autorisé'); return bodyInvestisseurId; } /** Vérifie qu'un deal appartient bien à l'utilisateur courant (et au workspace actif), le renvoie. */ function ownedDeal(req, id) { const row = db.prepare(` SELECT ipe.* FROM investissements_pe ipe JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE ipe.id = ? AND inv.user_id = ? AND ipe.workspace_id = ? `).get(id, req.user.id, resolveActiveWorkspaceId(req)); if (!row) throw new HttpError(404, 'Not found'); return row; } /** * GET /api/investissements-pe * ?scope=all → agrège tous les investisseurs de l'utilisateur (vue "Famille") * (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id */ router.get('/', (req, res) => { const scopeAll = req.query.scope === 'all'; const userId = req.user.id; let invCond, invArgs; if (scopeAll) { invCond = 'inv.user_id = ?'; invArgs = [userId]; } else { const raw = req.header('X-Investisseur-Id'); const id = Number(raw); if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId); if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); invCond = 'ipe.investisseur_id = ?'; invArgs = [id]; } const workspaceId = resolveActiveWorkspaceId(req); // Filtre optionnel par plateforme (19/09/26, page Plateformes PE) — mirroir du filtre // plateforme_id déjà supporté par GET /depots-retraits et GET /remboursements. const { plateforme_id } = req.query; const platCond = plateforme_id ? 'AND ipe.plateforme_id = ?' : ''; const platArgs = plateforme_id ? [Number(plateforme_id)] : []; const rows = db.prepare(` SELECT ipe.*, p.nom AS plateforme_nom, p.logo_filename AS plateforme_logo, inv.nom AS investisseur_nom, (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, -- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé" -- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora, -- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET -- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé. 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 FROM investissements_pe ipe JOIN plateformes p ON p.id = ipe.plateforme_id JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE ${invCond} AND ipe.workspace_id = ? ${platCond} ORDER BY ipe.date_souscription DESC, ipe.id DESC `).all(...invArgs, workspaceId, ...platArgs); const withTvpi = rows.map(r => ({ ...r, tvpi: r.derniere_valorisation != null ? Math.round(((r.derniere_valorisation + r.distributions_cumulees) / r.montant_investi) * 100) / 100 : null, })); res.json(withTvpi); }); /** * GET /api/investissements-pe/valorisations * Historique complet des valorisations, tous deals confondus (workspace * actif) — nécessaire pour reconstituer une courbe de valorisation du * portefeuille (page Investissements PE : graphique "Valorisation totale" * et onglet "Vision mensuelle"), ce que l'historique par deal seul * (GET /:id/valorisations) ne permet pas sans un aller-retour par deal. * ?scope=all → agrège tous les investisseurs de l'utilisateur (comme GET /). */ router.get('/valorisations', (req, res) => { const scopeAll = req.query.scope === 'all'; const userId = req.user.id; let invCond, invArgs; if (scopeAll) { invCond = 'inv.user_id = ?'; invArgs = [userId]; } else { const raw = req.header('X-Investisseur-Id'); const id = Number(raw); if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId); if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); invCond = 'ipe.investisseur_id = ?'; invArgs = [id]; } const workspaceId = resolveActiveWorkspaceId(req); const rows = db.prepare(` SELECT v.id, v.investissement_pe_id, v.date_valorisation, v.valorisation_nette FROM investissements_pe_valorisations v JOIN investissements_pe ipe ON ipe.id = v.investissement_pe_id JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE ${invCond} AND ipe.workspace_id = ? ORDER BY v.date_valorisation ASC, v.id ASC `).all(...invArgs, workspaceId); res.json(rows); }); /** * GET /api/investissements-pe/mouvements-portefeuille — 19/09/26, demande Olivier : "le suivi * de mouvement du porte-monnaie doit être commun [crowdlending + PE], avec la possibilité de * distinguer si l'on parle de crowdlending ou de PE". * * Contexte du bug signalé : l'onglet "Mouvements Porte-monnaie" (Plateformes.jsx, calcul du * solde indicatif ligne à ligne via MouvementsShared.computeSoldeIndicatifMap) ne consomme que * des endpoints bornés au workspace actif (GET /remboursements, /depots-retraits, * /frais-operations, /investissements) — cohérent pour séparer la performance crowdlending de * la performance PE, mais faux pour le porte-monnaie : sur une plateforme partagée (ex. * Fundora), l'argent est UN SEUL porte-monnaie physique chez le courtier, alimenté ou ponctionné * indifféremment par les deux activités. Un remboursement PE réutilisé pour souscrire un deal * crowdlending devient alors invisible du côté crowdlending, et le cumul plonge artificiellement * dans le négatif après chaque retrait — exactement le symptôme observé sur Fundora (3,45 €). * * Même principe déjà retenu pour solde_portefeuille (dashboard.js / dashboardPe.js, walletMap, * 17/09/26) : cet endpoint-ci fournit la partie du porte-monnaie qui vit HORS du workspace * actuellement actif (typiquement PE, quand appelé depuis Plateformes.jsx en workspace * crowdlending), pour que le frontend la fusionne avec ce qu'il a déjà côté workspace actif — * sans double-compte, et sans avoir à assouplir le scope workspace_id des endpoints existants. * Quatre familles de lignes, déjà mises en forme proche de MouvementsShared (type/montant/ * date_operation/plateforme_id/libelle) pour limiter le travail de fusion côté frontend : * - souscription : capital déployé à la souscription de chaque deal PE (montant_investi), * pas encore modélisé comme mouvement ailleurs (pendant PE de la ligne "souscription" * synthétique que Plateformes.jsx génère déjà pour le crowdlending). * - interets_plateforme : distributions PE (remboursements.type='interets_plateforme'), * créditées pour leur net_recu — même convention que interetsPlateformeWalletPerPlat dans * dashboardPe.js ("net_recu tient déjà compte des prélèvements"). * - frais : frais_operations rattachés à investissement_pe_id, jusqu'ici absents aussi du * calcul officiel du solde (cf. correctif fraisPerPlat du même jour dans dashboard*.js). * - depot / retrait : dépôts/retraits manuels (table depots_retraits) enregistrés alors que * le workspace PE était actif — AJOUTÉ le 19/09/26 suite au signalement Olivier sur Fundora, * où 4 dépôts manuels de 800 € (un par deal PE) étaient invisibles du porte-monnaie * crowdlending, expliquant l'essentiel du solde indicatif faussé (bien plus que les 3,45 € * de distribution initialement en cause). Filtrées sur workspace_id != workspace appelant * (résolu comme à l'écriture, cf. resolveActiveWorkspaceId) plutôt que sur un id "PE" en * dur, pour rester correct si un 3e workspace apparaît un jour. `raw_source` porte la valeur * brute de depots_retraits.source (manuel/import_excel/auto_remboursement) pour que le * frontend puisse reconstituer le badge "AUTO" comme pour les dépôts crowdlending. * * corrections_solde n'a PAS de colonne workspace_id (vérifié en base) : aucune correction ne * peut donc être "perdue" par ce bug, ce n'était pas un vrai gap malgré ce qu'un premier passage * avait supposé par prudence. */ router.get('/mouvements-portefeuille', (req, res) => { const scopeAll = req.query.scope === 'all'; const userId = req.user.id; let invCond, invArgs, invCondDr, invArgsDr; if (scopeAll) { invCond = 'inv.user_id = ?'; invArgs = [userId]; invCondDr = 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; invArgsDr = [userId]; } else { const raw = req.header('X-Investisseur-Id'); const id = Number(raw); if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId); if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); invCond = 'ipe.investisseur_id = ?'; invArgs = [id]; invCondDr = 'dr.investisseur_id = ?'; invArgsDr = [id]; } // Volontairement PAS de filtre workspace_id sur souscriptions/distributions/frais (tables // dédiées PE) : ce sont par construction des lignes qui vivent hors du workspace actif // (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée // entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas // dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé. // // ?native=1 (19/09/26, page Plateformes PE) : bascule ce même endpoint pour un appel fait // DEPUIS le workspace PE lui-même (plutôt que depuis crowdlending) — dans ce sens, les // depots_retraits à renvoyer sont ceux DU workspace appelant (workspace_id = callerWorkspaceId), // pas ceux d'un autre workspace. Les souscriptions/distributions/frais PE restent inchangées // (déjà natives, sans filtre workspace_id — limite connue si plusieurs workspaces PE existent // un jour, cf. commentaire ci-dessus, non résolue ici). const callerWorkspaceId = resolveActiveWorkspaceId(req); const native = req.query.native === '1'; const souscriptions = db.prepare(` SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom, plat_inv.nom AS plateforme_detenteur_nom, ipe.date_souscription AS date_operation, ipe.montant_investi AS montant, ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom FROM investissements_pe ipe JOIN plateformes p ON p.id = ipe.plateforme_id LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE ${invCond} `).all(...invArgs); const distributions = db.prepare(` SELECT r.id AS remb_id, ipe.plateforme_id, p.nom AS plateforme_nom, plat_inv.nom AS plateforme_detenteur_nom, r.date_remb AS date_operation, r.net_recu AS montant, ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom FROM remboursements r JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id JOIN plateformes p ON p.id = ipe.plateforme_id LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE r.type = 'interets_plateforme' AND ${invCond} `).all(...invArgs); const frais = db.prepare(` SELECT f.id AS frais_id, f.plateforme_id, p.nom AS plateforme_nom, plat_inv.nom AS plateforme_detenteur_nom, f.date_operation, f.montant, f.mode_reglement, ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom FROM frais_operations f JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id JOIN plateformes p ON p.id = f.plateforme_id LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE ${invCond} `).all(...invArgs); const drWorkspaceOp = native ? '=' : '!='; const depots = db.prepare(` SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom, plat_inv.nom AS plateforme_detenteur_nom, dr.date_operation, dr.type, dr.montant, dr.source AS raw_source, dr.libelle, dr.investisseur_id, inv.nom AS investisseur_nom FROM depots_retraits dr JOIN plateformes p ON p.id = dr.plateforme_id LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id JOIN investisseurs inv ON inv.id = dr.investisseur_id WHERE dr.workspace_id ${drWorkspaceOp} ? AND ${invCondDr} `).all(callerWorkspaceId, ...invArgsDr); const rows = [ ...souscriptions.map(r => ({ id: `pe_sub_${r.deal_id}`, type: 'souscription', montant: r.montant, date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, })), ...distributions.map(r => ({ id: `pe_remb_${r.remb_id}`, type: 'interets_plateforme', montant: r.montant, date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, })), ...frais.map(r => ({ id: `pe_frais_${r.frais_id}`, type: 'frais', montant: r.montant, mode_reglement: r.mode_reglement, date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, })), ...depots.map(r => ({ id: `pe_dr_${r.dr_id}`, type: r.type, montant: r.montant, raw_source: r.raw_source, date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle, investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom, })), ]; res.json(rows); }); router.post('/', requireInvestisseur, (req, res, next) => { try { const body = Schema.parse(req.body); const investisseurId = resolveInvestisseurId(req, body.investisseur_id); const workspaceId = resolveActiveWorkspaceId(req); const r = db.prepare(` INSERT INTO investissements_pe (investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription, duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes, mode_detention) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null, body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null, body.objectif || null, body.statut, body.reference || null, body.notes || null, body.mode_detention, ); res.status(201).json({ id: r.lastInsertRowid, ...body }); } catch (e) { next(e); } }); /** * GET /api/investissements-pe/:id — fiche détaillée d'un deal (chantier "fiche détail PE", * 17/09/26). Même enrichissement que GET / (dernière valorisation, distributions cumulées, * TVPI) mais pour une seule ligne — évite de renvoyer toute la liste juste pour afficher une * fiche. requireInvestisseur n'est pas utilisé ici (ownedDeal vérifie déjà la propriété par * user_id + workspace actif, sans dépendre de l'investisseur sélectionné côté client — la fiche * doit rester accessible même si l'utilisateur a changé de sélecteur détenteur entre-temps). */ router.get('/:id', (req, res, next) => { try { const id = Number(req.params.id); ownedDeal(req, id); const row = db.prepare(` SELECT ipe.*, p.nom AS plateforme_nom, p.logo_filename AS plateforme_logo, inv.nom AS investisseur_nom, (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, -- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé" -- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora, -- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET -- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé. 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, -- Idem, pour la part "plus-value réalisée" des distributions (20/09/26, chantier -- "Fiscalité PE" — cf. distribution_nature dans RembFormModal.jsx). Inclus dans -- distributions_cumulees ET dans le TVPI, isolé ici pour l'affichage/le calcul fiscal. COALESCE((SELECT SUM(r.net_recu) FROM remboursements r WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'plus_value'), 0) AS plus_value_cumulee, -- Idem, pour la part "restitution de capital déployé" — sert à préfiller le montant par -- défaut de la modale "Déclarer une perte définitive" (capital pas encore restitué). COALESCE((SELECT SUM(r.net_recu) FROM remboursements r WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'capital'), 0) AS capital_restitue_cumule FROM investissements_pe ipe JOIN plateformes p ON p.id = ipe.plateforme_id JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE ipe.id = ? `).get(id); if (!row) throw new HttpError(404, 'Not found'); row.tvpi = row.derniere_valorisation != null ? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100 : null; // Pertes définitives (chantier "Fiscalité PE", 20/09/26) — au plus une ligne active à la // fois en pratique (statut repasse à perte_definitive), mais l'historique complet // (déclarations annulées comprises) est conservé pour traçabilité, comme // investissement_pertes côté crowdlending. row.pertes = db.prepare( 'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id ASC' ).all(id); res.json(row); } catch (e) { next(e); } }); /** * GET /api/investissements-pe/:id/export — dossier ZIP d'un deal PE (18/09/26, demande * Olivier — menu "⋮" de la fiche détail, inspiré du même menu côté crowdlending). Même * principe que GET /investissements/:id/export (manifest.json + documents/), mais le * contenu du manifeste reflète le modèle de données PE, plus réduit : pas d'échéancier * prévisionnel, pas d'historique de modifications, pas de révisions de conditions, pas de * pertes ni réinvestissements pour cette table (concepts qui n'existent pas côté PE) — en * repli, valorisations + remboursements (distributions) + frais_operations + documents. */ router.get('/:id/export', (req, res, next) => { try { const id = Number(req.params.id); ownedDeal(req, id); const meta = db.prepare(` SELECT ipe.*, p.nom AS plateforme_nom, inv.nom AS investisseur_nom FROM investissements_pe ipe JOIN plateformes p ON p.id = ipe.plateforme_id JOIN investisseurs inv ON inv.id = ipe.investisseur_id WHERE ipe.id = ? `).get(id); const valorisations = db.prepare( 'SELECT date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes FROM investissements_pe_valorisations WHERE investissement_pe_id = ? ORDER BY date_valorisation ASC, id ASC' ).all(id); const remboursements = db.prepare( 'SELECT date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, distribution_nature, notes FROM remboursements WHERE investissement_pe_id = ? ORDER BY date_remb ASC' ).all(id); const frais = db.prepare( 'SELECT date_operation, montant, mode_reglement, origine, categorie, notes FROM frais_operations WHERE investissement_pe_id = ? ORDER BY date_operation ASC' ).all(id); const documents = db.prepare( "SELECT * FROM documents WHERE user_id = ? AND entity_type = 'investissement_pe' AND entity_id = ? ORDER BY created_at ASC" ).all(req.user.id, id); // Fichiers du zip + métadonnées correspondantes dans le manifeste — même logique de // dédoublonnage des noms que l'export crowdlending (cf. investissements.js). const usedNames = new Set(); const documentsMeta = []; const entries = []; for (const doc of documents) { const baseLabel = sanitizeZipPart(doc.nom_affichage); let finalName = `${baseLabel}.${doc.extension}`; let n = 2; while (usedNames.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; } usedNames.add(finalName); const zipPath = `documents/${finalName}`; documentsMeta.push({ nom_affichage: doc.nom_affichage, nom_original: doc.nom_original, extension: doc.extension, mime_type: doc.mime_type, taille_octets: doc.taille_octets, categorie: doc.categorie, zip_path: zipPath, }); const filePath = path.join(docsDir, doc.filename); if (fs.existsSync(filePath)) entries.push({ name: zipPath, data: fs.readFileSync(filePath) }); } const manifest = { version: '1.0', type: 'dossier_investissement_pe', exported_at: new Date().toISOString(), investissement_pe: { nom_deal: meta.nom_deal, strategie: meta.strategie, date_souscription: meta.date_souscription, duree_mois: meta.duree_mois, montant_investi: meta.montant_investi, capital_investissable: meta.capital_investissable, objectif: meta.objectif, statut: meta.statut, reference: meta.reference, notes: meta.notes, }, plateforme: { nom: meta.plateforme_nom }, investisseur: { nom: meta.investisseur_nom }, valorisations, remboursements, frais_operations: frais, documents: documentsMeta, }; entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }); const zipBuf = createZip(entries); const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const filename = `Dossier_PE_${id}_${ts}.zip`; res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(zipBuf); } catch (e) { next(e); } }); router.put('/:id', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); ownedDeal(req, id); const body = Schema.parse(req.body); const investisseurId = resolveInvestisseurId(req, body.investisseur_id); db.prepare(` UPDATE investissements_pe SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?, duree_mois=?, montant_investi=?, capital_investissable=?, objectif=?, statut=?, reference=?, notes=?, mode_detention=?, updated_at=datetime('now') WHERE id=? `).run( investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null, body.objectif || null, body.statut, body.reference || null, body.notes || null, body.mode_detention, id, ); res.json({ id, ...body }); } catch (e) { next(e); } }); router.delete('/:id', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); ownedDeal(req, id); // Les distributions déjà saisies (remboursements liés) restent en base, // simplement détachées du deal supprimé — jamais de perte de données // financières pour libérer une contrainte de clé étrangère. db.prepare('UPDATE remboursements SET investissement_pe_id = NULL WHERE investissement_pe_id = ?').run(id); db.prepare('DELETE FROM investissements_pe WHERE id = ?').run(id); res.status(204).end(); } catch (e) { next(e); } }); /* ── Perte définitive (chantier "Fiscalité PE", 20/09/26) ─────── * POST /:id/perte-definitive — déclare un deal en perte définitive (capital réellement * irrécouvrable : fonds liquidé sans distribution, société sous-jacente radiée…). * Contrairement au crowdlending, un deal PE n'a pas de statut intermédiaire de * recouvrement (en_retard/procedure) : n'importe quel statut autre que * 'perte_definitive' peut y transiter directement. * DELETE /:id/perte-definitive — annule la dernière déclaration, restaure le statut précédent. */ router.post('/:id/perte-definitive', requireInvestisseur, (req, res, next) => { try { const body = PerteSchema.parse(req.body); const id = Number(req.params.id); const deal = ownedDeal(req, id); if (deal.statut === 'perte_definitive') { throw new HttpError(400, 'Ce deal est déjà déclaré en perte définitive.'); } let perteId; const tx = db.transaction(() => { const r = db.prepare(` INSERT INTO investissement_pe_pertes (investissement_pe_id, date_effet, montant_perte, ancien_statut, motif) VALUES (?,?,?,?,?) `).run(id, body.date_effet, body.montant_perte, deal.statut, body.motif); perteId = r.lastInsertRowid; db.prepare(` UPDATE investissements_pe SET statut = 'perte_definitive', updated_at = datetime('now') WHERE id = ? `).run(id); }); tx(); const perte = db.prepare('SELECT * FROM investissement_pe_pertes WHERE id = ?').get(perteId); res.status(201).json(perte); } catch (e) { next(e); } }); router.delete('/:id/perte-definitive', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); const deal = ownedDeal(req, id); if (deal.statut !== 'perte_definitive') { throw new HttpError(400, "Ce deal n'est pas déclaré en perte définitive."); } const derniere = db.prepare( 'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id DESC LIMIT 1' ).get(id); const statutRestaure = derniere?.ancien_statut || 'valide'; const tx = db.transaction(() => { if (derniere) db.prepare('DELETE FROM investissement_pe_pertes WHERE id = ?').run(derniere.id); db.prepare(` UPDATE investissements_pe SET statut = ?, updated_at = datetime('now') WHERE id = ? `).run(statutRestaure, id); }); tx(); res.status(204).end(); } catch (e) { next(e); } }); /* ── Historique des valorisations ────────────────────────────── */ router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); ownedDeal(req, id); const rows = db.prepare(` SELECT * FROM investissements_pe_valorisations WHERE investissement_pe_id = ? ORDER BY date_valorisation DESC, id DESC `).all(id); res.json(rows); } catch (e) { next(e); } }); router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); const deal = ownedDeal(req, id); const body = ValorisationSchema.parse(req.body); // Quand la répartition est renseignée (capital_deploye présent), la valorisation nette est // recalculée ici plutôt que reprise du body : capital_deploye + plus_value_latente + // capital_non_deploye (déduit = capital investissable du deal − capital_deploye, jamais // négatif). Capital investissable = montant_investi net des frais "à la source" prélevés à // la souscription quand ils sont connus (deal.capital_investissable), sinon montant_investi // brut par repli (18/09/26, correctif Olivier — jusque-là on utilisait toujours le montant // brut, ce qui gonflait à tort le capital non déployé pour un deal avec frais à la source). // Sans répartition (mode simple, historique), comportement inchangé : le montant saisi est // utilisé tel quel et les deux colonnes restent NULL. let valorisationNette = body.valorisation_nette; let capitalDeploye = null; let plusValueLatente = null; if (body.capital_deploye != null) { const capitalInvestissable = deal.capital_investissable ?? deal.montant_investi; capitalDeploye = body.capital_deploye; plusValueLatente = body.plus_value_latente ?? 0; const capitalNonDeploye = Math.max(0, capitalInvestissable - capitalDeploye); valorisationNette = Math.round((capitalDeploye + plusValueLatente + capitalNonDeploye) * 100) / 100; } const r = db.prepare(` INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes) VALUES (?,?,?,?,?,?) `).run(id, body.date_valorisation, valorisationNette, capitalDeploye, plusValueLatente, body.notes || null); res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, date_valorisation: body.date_valorisation, valorisation_nette: valorisationNette, capital_deploye: capitalDeploye, plus_value_latente: plusValueLatente, notes: body.notes, }); } catch (e) { next(e); } }); router.delete('/:id/valorisations/:valId', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); ownedDeal(req, id); const r = db.prepare(` DELETE FROM investissements_pe_valorisations WHERE id = ? AND investissement_pe_id = ? `).run(Number(req.params.valId), id); if (r.changes === 0) throw new HttpError(404, 'Not found'); res.status(204).end(); } catch (e) { next(e); } }); export default router;