Implementation de la notion d'espace d'investissement

This commit is contained in:
ocroguennec committed 2026-09-17 18:25:42 +02:00
1 parent 6b6cad7cd6
commit 4b7df66862
54 files changed
+6782 -288

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.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(),
objectif: z.string().optional(),
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
reference: z.string().optional(),
notes: z.string().optional(),
});
const ValorisationSchema = z.object({
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
valorisation_nette: z.number().nonnegative(),
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);
const rows = db.prepare(`
SELECT ipe.*,
p.nom AS plateforme_nom,
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
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 = ?
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...invArgs, workspaceId);
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);
});
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, objectif, statut, reference, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.objectif || null,
body.statut, body.reference || null, body.notes || null,
);
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,
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
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;
res.json(row);
} 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=?, objectif=?, statut=?, reference=?, notes=?,
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.objectif || null, body.statut,
body.reference || null, body.notes || null, 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); }
});
/* ── 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);
ownedDeal(req, id);
const body = ValorisationSchema.parse(req.body);
const r = db.prepare(`
INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, notes)
VALUES (?,?,?,?)
`).run(id, body.date_valorisation, body.valorisation_nette, body.notes || null);
res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, ...body });
} 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;