Implementation de la notion d'espace d'investissement
This commit is contained in:
1 parent
6b6cad7cd6
commit
4b7df66862
54 files changed
+6782
-288
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user