Files
crowdlending-app/backend/src/routes/investissementsPe.js
T
2026-09-26 11:15:26 +02:00

711 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;