711 lines
35 KiB
JavaScript
711 lines
35 KiB
JavaScript
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;
|