Améliorations diverses

This commit is contained in:
ocroguennec committed 2026-09-19 19:00:12 +02:00
1 parent 8817ac693d
commit 49fab06012
15 files changed
+908 -147

No files matched your search

+12
View File
@@ -3445,6 +3445,18 @@ if (!invPeCols.includes('capital_investissable')) {
`);
}
// Mode de détention du deal PE (19/09/26, demande Olivier) — prépare le terrain pour un futur
// calcul fiscal PE sans le construire maintenant (cf. project_workspaces_transformation.md) :
// 'direct' (compte-titres classique, flat tax au moment du versement, ex. Fundora) | 'pea_pme'
// (enveloppe PEA/PEA-PME, ex. Baltis) | 'assurance_vie'. Même principe que fiscalite_override
// côté crowdlending (cf. project_fiscalite_override.md), mais en énumération à 3 valeurs plutôt
// qu'un booléen "exonéré ou pas" : PEA-PME et assurance-vie ont des régimes fiscaux différents
// l'un de l'autre, pas juste "exonéré ou pas". Informatif pour l'instant, aucun calcul fiscal
// n'en dépend encore.
if (!invPeCols.includes('mode_detention')) {
db.exec("ALTER TABLE investissements_pe ADD COLUMN mode_detention TEXT NOT NULL DEFAULT 'direct'");
}
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
// de capital, plus-value) perçue sur un deal PE est saisie comme un
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
+31 -9
View File
@@ -106,12 +106,18 @@ function assertUniqueSouscription(body, categorie, excludeId) {
/**
* GET /api/frais-operations?investissement_id=123 | ?investissement_pe_id=456
* Sans filtre : tous les frais de l'utilisateur pour le workspace ACTIF (résolu via
* resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending ou PE selon le slug
* du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/investisseur_nom (vue
* portefeuille, ex. page dédiée Frais/FraisPe). Le filtre par investissement_id/
* Sans filtre : tous les frais du détenteur actif (header X-Investisseur-Id) pour le
* workspace ACTIF (résolu via resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending
* ou PE selon le type du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/
* investisseur_nom (vue portefeuille, ex. page dédiée Frais/FraisPe). ?scope=all agrège tous
* les détenteurs de l'utilisateur (vue "Famille et entreprises"), même mécanisme que GET
* /investissements-pe, /depots-retraits et /remboursements (19/09/26 — jusqu'ici cette route
* ignorait totalement le détenteur actif et renvoyait toujours tout le foyer, contrairement à
* ces trois autres routes ; bug remonté par Olivier : la page Frais ne filtrait jamais par
* détenteur, même un détenteur précis sélectionné). Le filtre par investissement_id/
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
* côté client.
* côté client, et reste volontairement family-wide (propriété déjà vérifiée par
* assertOwnedInvestissement(Pe) sur le deal précis demandé).
*/
router.get('/', (req, res, next) => {
try {
@@ -138,6 +144,22 @@ router.get('/', (req, res, next) => {
} else {
const workspaceId = resolveActiveWorkspaceId(req);
const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId);
const scopeAll = req.query.scope === 'all';
let invCond, invArgs;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [req.user.id];
} 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, req.user.id);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'inv.id = ?';
invArgs = [id];
}
if (!ws || ws.type === 'crowdlending') {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
@@ -146,9 +168,9 @@ router.get('/', (req, res, next) => {
JOIN investisseurs inv ON inv.id = i.investisseur_id
JOIN plateformes p ON p.id = i.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE inv.user_id = ?
WHERE ${invCond}
ORDER BY f.date_operation DESC, f.id DESC
`).all(req.user.id);
`).all(...invArgs);
} else {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
@@ -157,9 +179,9 @@ router.get('/', (req, res, next) => {
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE inv.user_id = ? AND ipe.workspace_id = ?
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(req.user.id, workspaceId);
`).all(...invArgs, workspaceId);
}
}
res.json(rows);
+26 -8
View File
@@ -44,6 +44,8 @@ const Schema = z.object({
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'),
});
const ValorisationSchema = z.object({
@@ -102,6 +104,11 @@ router.get('/', (req, res) => {
}
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.*,
@@ -126,9 +133,9 @@ router.get('/', (req, res) => {
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 = ?
WHERE ${invCond} AND ipe.workspace_id = ? ${platCond}
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...invArgs, workspaceId);
`).all(...invArgs, workspaceId, ...platArgs);
const withTvpi = rows.map(r => ({
...r,
@@ -252,7 +259,15 @@ router.get('/mouvements-portefeuille', (req, res) => {
// (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,
@@ -292,6 +307,7 @@ router.get('/mouvements-portefeuille', (req, res) => {
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,
@@ -301,7 +317,7 @@ router.get('/mouvements-portefeuille', (req, res) => {
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 != ? AND ${invCondDr}
WHERE dr.workspace_id ${drWorkspaceOp} ? AND ${invCondDr}
`).all(callerWorkspaceId, ...invArgsDr);
const rows = [
@@ -342,13 +358,14 @@ router.post('/', requireInvestisseur, (req, res, next) => {
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
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.statut, body.reference || null, body.notes || null, body.mode_detention,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
@@ -505,12 +522,13 @@ router.put('/:id', requireInvestisseur, (req, res, next) => {
UPDATE investissements_pe
SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?,
duree_mois=?, montant_investi=?, capital_investissable=?, objectif=?, statut=?,
reference=?, notes=?, updated_at=datetime('now')
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, id,
body.objectif || null, body.statut, body.reference || null, body.notes || null,
body.mode_detention, id,
);
res.json({ id, ...body });
} catch (e) { next(e); }