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
@@ -184,7 +184,13 @@ router.get('/me', requireAuth, (req, res) => {
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE inv.user_id = users.id
|
||||
) AS has_frais
|
||||
) AS has_frais,
|
||||
EXISTS(
|
||||
SELECT 1 FROM frais_operations f
|
||||
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE inv.user_id = users.id
|
||||
) AS has_frais_pe
|
||||
FROM users WHERE id = ?
|
||||
`)
|
||||
.get(req.user.id);
|
||||
|
||||
@@ -125,6 +125,19 @@ router.get('/', (req, res) => {
|
||||
GROUP BY i.plateforme_id
|
||||
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Capital investi Private Equity (investissements_pe.montant_investi) — une
|
||||
// plateforme peut être partagée entre le workspace crowdlending et le
|
||||
// workspace Private Equity (ex. Fundora) : le porte-monnaie réel chez le
|
||||
// courtier est UNIQUE, donc le capital englouti dans un deal PE doit aussi
|
||||
// réduire le porte-monnaie affiché ici, même si cette page est celle du
|
||||
// workspace crowdlending (17/09/26).
|
||||
const capitalInvestiPePerPlat = db.prepare(`
|
||||
SELECT plateforme_id, COALESCE(SUM(montant_investi), 0) AS capital_investi
|
||||
FROM investissements_pe
|
||||
WHERE ${invWhere}${asOf ? ' AND date_souscription <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Frais d'opération (Étape 3, 31/08/26) : un frais réglé 'source' ou 'portefeuille'
|
||||
// réduit le solde du porte-monnaie. 'source' s'ajoute à la réduction déjà appliquée via
|
||||
// capitalInvestiPerPlat (qui soustrait montant_investi déjà net) — cas confirmé par Olivier
|
||||
@@ -158,6 +171,7 @@ router.get('/', (req, res) => {
|
||||
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
|
||||
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
|
||||
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of capitalInvestiPePerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
|
||||
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
|
||||
|
||||
@@ -358,6 +372,14 @@ router.get('/solde-historique', (req, res) => {
|
||||
AND i.date_souscription <= ?
|
||||
`).get(date, platId, ...invParams, date);
|
||||
|
||||
// Capital investi Private Equity sur cette même plateforme (cf. GET / —
|
||||
// capitalInvestiPePerPlat) : même porte-monnaie physique, même correction.
|
||||
const capitalPe = db.prepare(`
|
||||
SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi
|
||||
FROM investissements_pe
|
||||
WHERE plateforme_id = ? AND ${invWhere} AND date_souscription <= ?
|
||||
`).get(platId, ...invParams, date);
|
||||
|
||||
/* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */
|
||||
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant.
|
||||
const frais = db.prepare(`
|
||||
@@ -382,6 +404,7 @@ router.get('/solde-historique', (req, res) => {
|
||||
+ remb.remb_wallet
|
||||
+ bonus.bonus_wallet
|
||||
- capital.capital_investi
|
||||
- capitalPe.capital_investi
|
||||
- frais.total_frais
|
||||
+ corrections.total_correction
|
||||
) * 100) / 100;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/dashboard-pe
|
||||
*
|
||||
* Équivalent Private Equity de GET /api/dashboard (dashboard.js).
|
||||
*
|
||||
* Deux notions bien séparées ici (17/09/26, suite à un bug signalé par
|
||||
* Olivier) :
|
||||
* - cashByPlatform (Dépôts/Retraits/Diff affichés, KPIs du haut de page) :
|
||||
* borné au workspace actif — c'est une vue "intention" (combien j'ai
|
||||
* déposé/retiré POUR le Private Equity), légitimement différente de la
|
||||
* même vue côté crowdlending.
|
||||
* - solde_portefeuille (le porte-monnaie par plateforme) : agrégé sur TOUS
|
||||
* les workspaces, sans filtre workspace_id. Une plateforme peut être
|
||||
* partagée entre crowdlending et PE (ex. Fundora) ; son porte-monnaie
|
||||
* physique chez le courtier est UNIQUE, donc le calcul doit tenir compte
|
||||
* des dépôts/retraits/investissements des DEUX côtés, quel que soit le
|
||||
* workspace consulté. Formule strictement identique à celle de
|
||||
* GET /api/dashboard (dépôts - retraits manuels + remboursements portefeuille
|
||||
* + bonus/intérêts plateforme - capital investi crowdlending
|
||||
* (investissements + réinvestissements) - capital investi PE
|
||||
* (investissements_pe.montant_investi) - frais - + corrections) : les deux
|
||||
* endpoints doivent toujours reporter le MÊME porte-monnaie pour une
|
||||
* plateforme partagée.
|
||||
*
|
||||
* ?scope=all → agrège tous les investisseurs de l'utilisateur (sinon filtré par
|
||||
* l'en-tête X-Investisseur-Id, comme /api/dashboard).
|
||||
* ?asOf=YYYY-MM-DD → cumule uniquement jusqu'à cette date (snapshot fin d'année,
|
||||
* même principe que /api/dashboard, pour les comparaisons N/N-1 du frontend).
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const scopeAll = req.query.scope === 'all';
|
||||
const userId = req.user.id;
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
let invWhere, invParams;
|
||||
if (scopeAll) {
|
||||
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
|
||||
invParams = [userId];
|
||||
} else {
|
||||
const raw = req.header('X-Investisseur-Id');
|
||||
const invId = Number(raw);
|
||||
if (!invId) 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(invId, userId);
|
||||
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
|
||||
invWhere = 'investisseur_id = ?';
|
||||
invParams = [invId];
|
||||
}
|
||||
|
||||
const asOf = /^\d{4}-\d{2}-\d{2}$/.test(req.query.asOf || '') ? req.query.asOf : null;
|
||||
|
||||
/* ── Cash global (dépôts/retraits, workspace actif) ─────────────── */
|
||||
const cash = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN type='depot' THEN montant END),0) AS total_depots,
|
||||
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END),0) AS total_retraits
|
||||
FROM depots_retraits
|
||||
WHERE ${invWhere} AND workspace_id = ?${asOf ? ' AND date_operation <= ?' : ''}
|
||||
`).get(...invParams, workspaceId, ...(asOf ? [asOf] : []));
|
||||
|
||||
/* ── Dépôts/retraits par plateforme (plateformes du workspace actif uniquement) ── */
|
||||
const cashByPlatform = db.prepare(`
|
||||
SELECT p.id AS plateforme_id, p.nom,
|
||||
plat_inv.nom AS detenteur_nom,
|
||||
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant END),0) AS depots,
|
||||
COALESCE(SUM(CASE WHEN dr.type='retrait' THEN dr.montant END),0) AS retraits,
|
||||
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant
|
||||
WHEN dr.type='retrait' THEN -dr.montant END),0) AS solde_net
|
||||
FROM plateformes p
|
||||
JOIN plateforme_workspaces pw ON pw.plateforme_id = p.id AND pw.workspace_id = ?
|
||||
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
|
||||
LEFT JOIN depots_retraits dr ON dr.plateforme_id = p.id AND dr.workspace_id = ?
|
||||
AND ${invWhere.replace('investisseur_id', 'dr.investisseur_id')}
|
||||
${asOf ? 'AND dr.date_operation <= ?' : ''}
|
||||
WHERE p.user_id = ?
|
||||
GROUP BY p.id, p.nom, plat_inv.nom
|
||||
ORDER BY p.nom
|
||||
`).all(workspaceId, workspaceId, ...invParams, ...(asOf ? [asOf] : []), userId);
|
||||
|
||||
/* ── Solde porte-monnaie par plateforme ──────────────────────────────
|
||||
* Une plateforme peut être partagée entre plusieurs workspaces (ex.
|
||||
* Fundora, à la fois crowdlending et PE) : son porte-monnaie physique
|
||||
* chez le courtier est UNIQUE, donc ce calcul agrège TOUS les workspaces
|
||||
* (aucun filtre workspace_id ici, volontairement) plutôt que de se borner
|
||||
* au seul workspace actif — sinon le porte-monnaie PE ignorerait les
|
||||
* dépôts/investissements crowdlending sur cette même plateforme, comme
|
||||
* son miroir GET /api/dashboard ignorait jusqu'ici le capital PE
|
||||
* (bug signalé par Olivier le 17/09/26). Formule strictement identique à
|
||||
* celle de GET /api/dashboard (dashboard.js) — seul cashByPlatform
|
||||
* ci-dessus (Dépôts/Retraits affichés) reste borné au workspace actif,
|
||||
* lui, car il sert de vue "intention" par workspace, pas le solde réel. */
|
||||
const drManuelPerPlat = db.prepare(`
|
||||
SELECT plateforme_id,
|
||||
COALESCE(SUM(CASE WHEN type='depot' THEN montant ELSE 0 END), 0) AS depots,
|
||||
COALESCE(SUM(CASE WHEN type='retrait' AND COALESCE(source,'') != 'auto_remboursement'
|
||||
THEN montant ELSE 0 END), 0) AS retraits_manuels
|
||||
FROM depots_retraits
|
||||
WHERE ${invWhere}${asOf ? ' AND date_operation <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Remboursements crédités directement au portefeuille (type='normal',
|
||||
// méthode='portefeuille') — concept crowdlending uniquement (PE ne
|
||||
// connaît que bonus_plateforme/interets_plateforme), mais peut concerner
|
||||
// une plateforme partagée : à agréger ici aussi pour la même raison.
|
||||
const rembWalletPerPlat = db.prepare(`
|
||||
SELECT i.plateforme_id,
|
||||
COALESCE(SUM(
|
||||
CASE p.fiscalite
|
||||
WHEN 'flat_tax' THEN r.net_recu
|
||||
ELSE r.capital + r.cashback + r.interets_bruts
|
||||
END
|
||||
), 0) AS remb_wallet
|
||||
FROM remboursements r
|
||||
JOIN investissements i ON i.id = r.investissement_id
|
||||
JOIN plateformes p ON p.id = i.plateforme_id
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
AND r.type = 'normal'
|
||||
AND r.methode_remboursement = 'portefeuille'
|
||||
${asOf ? 'AND r.date_remb <= ?' : ''}
|
||||
GROUP BY i.plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const capitalInvestiPePerPlat = db.prepare(`
|
||||
SELECT plateforme_id, COALESCE(SUM(montant_investi), 0) AS capital_investi
|
||||
FROM investissements_pe
|
||||
WHERE ${invWhere}${asOf ? ' AND date_souscription <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Capital investi crowdlending (investissements + réinvestissements) —
|
||||
// même raison de partage de plateforme que ci-dessus.
|
||||
const capitalInvestiPerPlat = db.prepare(`
|
||||
SELECT i.plateforme_id,
|
||||
COALESCE(SUM(
|
||||
i.montant_investi
|
||||
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id${asOf ? ' AND rv.date_reinvestissement <= ?' : ''}), 0)
|
||||
), 0) AS capital_investi
|
||||
FROM investissements i
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
${asOf ? 'AND i.date_souscription <= ?' : ''}
|
||||
GROUP BY i.plateforme_id
|
||||
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const bonusWalletPerPlat = db.prepare(`
|
||||
SELECT bonus_plateforme_id AS plateforme_id,
|
||||
COALESCE(SUM(cashback), 0) AS bonus_wallet
|
||||
FROM remboursements
|
||||
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
|
||||
AND type IN ('bonus_parrainage', 'bonus_plateforme')
|
||||
${asOf ? 'AND date_remb <= ?' : ''}
|
||||
GROUP BY bonus_plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Intérêts plateforme : comme les bonus, ils créditent directement le
|
||||
// porte-monnaie de la plateforme (net_recu tient déjà compte des prélèvements).
|
||||
const interetsPlateformeWalletPerPlat = db.prepare(`
|
||||
SELECT bonus_plateforme_id AS plateforme_id,
|
||||
COALESCE(SUM(net_recu), 0) AS interets_plateforme_wallet
|
||||
FROM remboursements
|
||||
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
|
||||
AND type = 'interets_plateforme'
|
||||
${asOf ? 'AND date_remb <= ?' : ''}
|
||||
GROUP BY bonus_plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Frais d'opération et corrections de solde — concepts crowdlending
|
||||
// uniquement (cf. GET /api/dashboard), mais réduisent/ajustent le même
|
||||
// porte-monnaie physique sur une plateforme partagée.
|
||||
const fraisPerPlat = db.prepare(`
|
||||
SELECT f.plateforme_id,
|
||||
COALESCE(SUM(f.montant), 0) AS total_frais
|
||||
FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
AND f.mode_reglement IN ('source', 'portefeuille')
|
||||
${asOf ? 'AND f.date_operation <= ?' : ''}
|
||||
GROUP BY f.plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const correctionsPerPlat = db.prepare(`
|
||||
SELECT plateforme_id,
|
||||
COALESCE(SUM(montant), 0) AS total_correction
|
||||
FROM corrections_solde
|
||||
WHERE ${invWhere}${asOf ? ' AND date <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const walletMap = {};
|
||||
for (const r of drManuelPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.depots - r.retraits_manuels;
|
||||
for (const r of rembWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.remb_wallet;
|
||||
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
|
||||
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
|
||||
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of capitalInvestiPePerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
|
||||
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
|
||||
|
||||
const cashByPlatformEnriched = cashByPlatform.map(p => ({
|
||||
...p,
|
||||
solde_portefeuille: Math.round((walletMap[p.plateforme_id] || 0) * 100) / 100,
|
||||
}));
|
||||
|
||||
const solde_portefeuille_total = Math.round(
|
||||
cashByPlatformEnriched.reduce((s, p) => s + p.solde_portefeuille, 0) * 100
|
||||
) / 100;
|
||||
|
||||
res.json({ cash, cashByPlatform: cashByPlatformEnriched, solde_portefeuille_total });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -3,6 +3,7 @@ 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();
|
||||
|
||||
@@ -52,6 +53,12 @@ router.get('/', (req, res) => {
|
||||
|
||||
const { from, to, type, plateforme_id } = req.query;
|
||||
const conds = [invCond];
|
||||
// Étape 2c du retrofit workspace_id (17/09/26) : la lecture respecte désormais le
|
||||
// workspace actif (même résolution qu'à l'écriture, cf. workspaceScope.js). Sans
|
||||
// effet aujourd'hui (100% des lignes sont "crowdlending", seul workspace atteignable
|
||||
// sur cette page) — devient réellement filtrant dès qu'un 2e workspace existera.
|
||||
conds.push('dr.workspace_id = ?');
|
||||
args.push(resolveActiveWorkspaceId(req));
|
||||
if (from) { conds.push('dr.date_operation >= ?'); args.push(from); }
|
||||
if (to) { conds.push('dr.date_operation <= ?'); args.push(to); }
|
||||
if (type) { conds.push('dr.type = ?'); args.push(type); }
|
||||
@@ -73,13 +80,16 @@ router.post('/', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : chaque dépôt/retrait créé
|
||||
// manuellement est flêché vers le workspace actif au moment de la création.
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
const r = db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes)
|
||||
VALUES (?,?,?,?,?,?,?, 'manuel', ?)
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?, 'manuel', ?, ?)
|
||||
`).run(
|
||||
investisseurId, body.plateforme_id, body.date_operation, body.type,
|
||||
body.montant, body.libelle || null, body.reference || null, body.notes || null,
|
||||
body.montant, body.libelle || null, body.reference || null, body.notes || null, workspaceId,
|
||||
);
|
||||
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
||||
} catch (e) { next(e); }
|
||||
|
||||
@@ -195,6 +195,18 @@ function handleUpload(req, res, next) {
|
||||
if (!owned) throw new HttpError(404, 'Investissement introuvable');
|
||||
}
|
||||
|
||||
// Équivalent pour les deals Private Equity (chantier "fiche détail PE", 17/09/26) — même
|
||||
// vérification de propriété, jointure via investissements_pe au lieu de investissements.
|
||||
if (entityType === 'investissement_pe') {
|
||||
if (!entityId) throw new HttpError(400, 'entity_id est requis pour la catégorie investissement_pe');
|
||||
const owned = db.prepare(`
|
||||
SELECT ipe.id FROM investissements_pe ipe
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id AND inv.user_id = ?
|
||||
WHERE ipe.id = ?
|
||||
`).get(req.user.id, entityId);
|
||||
if (!owned) throw new HttpError(404, 'Investissement PE introuvable');
|
||||
}
|
||||
|
||||
const usage = getUsage(req.user.id);
|
||||
const { quotaBytes, quotaCount } = getQuotaConfig();
|
||||
if (usage.used_count + 1 > quotaCount) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,14 +17,16 @@ const router = Router();
|
||||
* Montant stocké : TTC uniquement (pas de détail HT/TVA, non pertinent pour
|
||||
* un particulier — cf. échange avec Olivier, 30/08/26).
|
||||
*
|
||||
* Étape 1 (fondations) : ce module fournit un CRUD complet, mais rien dans
|
||||
* l'application ne crée encore de frais_operations automatiquement — la
|
||||
* saisie à la souscription, la modale dédiée et l'intégration aux Mouvements
|
||||
* viendront dans une prochaine passe.
|
||||
* Depuis le 17/09/26 (chantier "frais PE"), une ligne se rattache SOIT à un
|
||||
* investissement crowdlending (investissement_id), SOIT à un deal Private
|
||||
* Equity (investissement_pe_id) — jamais les deux (CHECK en base). Le corps
|
||||
* de requête doit donc porter exactement l'un des deux champs ; toutes les
|
||||
* routes ci-dessous se branchent sur celui qui est renseigné.
|
||||
*/
|
||||
|
||||
const Schema = z.object({
|
||||
investissement_id: z.number().int().positive(),
|
||||
investissement_id: z.number().int().positive().nullable().optional(),
|
||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
||||
remboursement_id: z.number().int().positive().nullable().optional(),
|
||||
montant: z.number().positive(),
|
||||
mode_reglement: z.enum(['source', 'portefeuille', 'compte_courant']).default('source'),
|
||||
@@ -35,7 +38,10 @@ const Schema = z.object({
|
||||
// "Frais" du formulaire d'investissement (unique par investissement, cf. assertUniqueSouscription).
|
||||
categorie: z.enum(['souscription', 'gestion', 'distribution', 'autre']).default('autre'),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
}).refine(
|
||||
b => (b.investissement_id != null) !== (b.investissement_pe_id != null),
|
||||
{ message: 'Exactement un de investissement_id ou investissement_pe_id doit être renseigné' },
|
||||
);
|
||||
|
||||
function assertOwnedInvestissement(invId, userId) {
|
||||
const row = db.prepare(`
|
||||
@@ -47,10 +53,31 @@ function assertOwnedInvestissement(invId, userId) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function assertRemboursementBelongs(rembId, invId) {
|
||||
/** Équivalent pour les deals PE (investissements_pe) — bornée au workspace actif, même
|
||||
* principe que ownedDeal() dans investissementsPe.js. */
|
||||
function assertOwnedInvestissementPe(invPeId, req) {
|
||||
const row = db.prepare(`
|
||||
SELECT ipe.id, ipe.plateforme_id FROM investissements_pe ipe
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id AND inv.user_id = ?
|
||||
WHERE ipe.id = ? AND ipe.workspace_id = ?
|
||||
`).get(req.user.id, invPeId, resolveActiveWorkspaceId(req));
|
||||
if (!row) throw new HttpError(403, 'Investissement PE not in scope');
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Résout et vérifie la référence portée par le corps de requête (crowdlending ou PE),
|
||||
* renvoie { id, plateforme_id } de l'investissement/deal correspondant. */
|
||||
function assertOwnedInvestissementEither(body, req) {
|
||||
return body.investissement_id
|
||||
? assertOwnedInvestissement(body.investissement_id, req.user.id)
|
||||
: assertOwnedInvestissementPe(body.investissement_pe_id, req);
|
||||
}
|
||||
|
||||
function assertRemboursementBelongs(rembId, body) {
|
||||
if (!rembId) return;
|
||||
const remb = db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?')
|
||||
.get(rembId, invId);
|
||||
const remb = body.investissement_id
|
||||
? db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?').get(rembId, body.investissement_id)
|
||||
: db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_pe_id = ?').get(rembId, body.investissement_pe_id);
|
||||
if (!remb) throw new HttpError(400, "remboursement_id ne correspond pas à cet investissement");
|
||||
}
|
||||
|
||||
@@ -61,30 +88,34 @@ export function assertOwnedCompte(compteId, userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Un investissement ne peut avoir qu'UNE seule entrée de catégorie 'souscription' — c'est
|
||||
* l'entrée lue/écrite depuis l'étape "Frais" du formulaire de création/modification de
|
||||
* l'investissement (jamais depuis le bloc "Frais d'opération" de la fiche, qui exclut cette
|
||||
* catégorie de son sélecteur). `excludeId` : à passer lors d'un PUT pour ne pas se bloquer
|
||||
* soi-même.
|
||||
* Un investissement (ou un deal PE) ne peut avoir qu'UNE seule entrée de catégorie
|
||||
* 'souscription' — c'est l'entrée lue/écrite depuis l'étape "Frais" du formulaire de
|
||||
* création/modification (jamais depuis le bloc "Frais d'opération"/la page Frais, qui
|
||||
* excluent cette catégorie de leur sélecteur). `excludeId` : à passer lors d'un PUT pour
|
||||
* ne pas se bloquer soi-même.
|
||||
*/
|
||||
function assertUniqueSouscription(investissementId, categorie, excludeId) {
|
||||
function assertUniqueSouscription(body, categorie, excludeId) {
|
||||
if (categorie !== 'souscription') return;
|
||||
const col = body.investissement_id ? 'investissement_id' : 'investissement_pe_id';
|
||||
const val = body.investissement_id ?? body.investissement_pe_id;
|
||||
const existing = excludeId
|
||||
? db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription' AND id != ?`).get(investissementId, excludeId)
|
||||
: db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription'`).get(investissementId);
|
||||
? db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription' AND id != ?`).get(val, excludeId)
|
||||
: db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription'`).get(val);
|
||||
if (existing) throw new HttpError(409, 'Un frais de catégorie "souscription" existe déjà pour cet investissement');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/frais-operations?investissement_id=123
|
||||
* Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs), enrichis de
|
||||
* nom_projet/plateforme_nom/investisseur_nom (vue portefeuille, ex. page dédiée Frais — colonne
|
||||
* Détenteur et onglet "Investissements", Étape 11, 02/09/26) — le filtre par investissement_id
|
||||
* n'a pas besoin de cet enrichissement, le contexte est déjà connu côté client.
|
||||
* 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/
|
||||
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
|
||||
* côté client.
|
||||
*/
|
||||
router.get('/', (req, res, next) => {
|
||||
try {
|
||||
const { investissement_id } = req.query;
|
||||
const { investissement_id, investissement_pe_id } = req.query;
|
||||
let rows;
|
||||
if (investissement_id) {
|
||||
assertOwnedInvestissement(Number(investissement_id), req.user.id);
|
||||
@@ -95,17 +126,41 @@ router.get('/', (req, res, next) => {
|
||||
WHERE f.investissement_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(Number(investissement_id));
|
||||
} else {
|
||||
} else if (investissement_pe_id) {
|
||||
assertOwnedInvestissementPe(Number(investissement_pe_id), req);
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||
SELECT f.*, c.nom AS compte_nom
|
||||
FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
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 = ?
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE f.investissement_pe_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id);
|
||||
`).all(Number(investissement_pe_id));
|
||||
} else {
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
const ws = db.prepare('SELECT slug FROM workspaces WHERE id = ?').get(workspaceId);
|
||||
if (!ws || ws.slug === 'crowdlending') {
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||
FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
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 = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id);
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||
FROM frais_operations f
|
||||
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
|
||||
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 = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id, workspaceId);
|
||||
}
|
||||
}
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
@@ -114,19 +169,19 @@ router.get('/', (req, res, next) => {
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||
const inv = assertOwnedInvestissementEither(body, req);
|
||||
assertRemboursementBelongs(body.remboursement_id, body);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie);
|
||||
assertUniqueSouscription(body, body.categorie);
|
||||
|
||||
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO frais_operations
|
||||
(investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
(investissement_id, investissement_pe_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
);
|
||||
@@ -137,29 +192,27 @@ router.post('/', (req, res, next) => {
|
||||
router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.prepare(`
|
||||
SELECT f.id FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE f.id = ? AND inv.user_id = ?
|
||||
`).get(id, req.user.id);
|
||||
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
// Vérifie que l'utilisateur possédait déjà cette ligne, quel que soit son type actuel.
|
||||
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
|
||||
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
|
||||
|
||||
const body = Schema.parse(req.body);
|
||||
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||
const inv = assertOwnedInvestissementEither(body, req);
|
||||
assertRemboursementBelongs(body.remboursement_id, body);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie, id);
|
||||
assertUniqueSouscription(body, body.categorie, id);
|
||||
|
||||
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE frais_operations
|
||||
SET investissement_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
|
||||
SET investissement_id=?, investissement_pe_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
|
||||
compte_id=?, date_operation=?, origine=?, categorie=?, notes=?, updated_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(
|
||||
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
id,
|
||||
@@ -171,13 +224,10 @@ router.put('/:id', (req, res, next) => {
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.prepare(`
|
||||
SELECT f.id FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE f.id = ? AND inv.user_id = ?
|
||||
`).get(id, req.user.id);
|
||||
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
|
||||
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
|
||||
|
||||
db.prepare('DELETE FROM frais_operations WHERE id = ?').run(id);
|
||||
res.status(204).end();
|
||||
|
||||
@@ -9,6 +9,7 @@ xlsx.set_fs(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 { generateSimul, generateSimulWithReinvestissements, adjustSimulForActuals } from '../utils/schedule.js';
|
||||
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
|
||||
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
|
||||
@@ -359,6 +360,9 @@ router.post('/apply', (req, res, next) => {
|
||||
const cur = platMinDate.get(plateformeId);
|
||||
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
|
||||
};
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : résolu une seule fois pour
|
||||
// tout le lot importé (même job = même workspace actif).
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (let idx = 0; idx < rows.length; idx++) {
|
||||
@@ -394,8 +398,8 @@ router.post('/apply', (req, res, next) => {
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
req.investisseur.id,
|
||||
plateformeId,
|
||||
@@ -405,6 +409,7 @@ router.post('/apply', (req, res, next) => {
|
||||
v('libelle') || null,
|
||||
v('reference') || null,
|
||||
srcLabel,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
} else if (module === 'investissements') {
|
||||
@@ -468,14 +473,15 @@ router.post('/apply', (req, res, next) => {
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
(investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux,
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
investissementId,
|
||||
dateRemb,
|
||||
capital, cashback, bruts, ps, pf, interets_nets, net_recu,
|
||||
v('statut') || 'paye',
|
||||
srcLabel,
|
||||
workspaceId,
|
||||
);
|
||||
affectedInvestissementIds.add(investissementId);
|
||||
|
||||
@@ -721,6 +727,9 @@ router.post('/dossier/apply', (req, res, next) => {
|
||||
|
||||
let action, investissementId;
|
||||
let docsInserted = 0, docsSkipped = 0;
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : résolu une seule fois
|
||||
// pour tout le dossier importé (même job = même workspace actif).
|
||||
const dossierWorkspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
/* ── 1. Résoudre / créer la plateforme ─────────────────── */
|
||||
@@ -768,14 +777,15 @@ router.post('/dossier/apply', (req, res, next) => {
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
(investissement_id, date_remb, capital, cashback, interets_bruts,
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
|
||||
`).run(
|
||||
investissementId, rb.date_remb,
|
||||
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
|
||||
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
|
||||
rb.interets_nets || 0, rb.net_recu || 0,
|
||||
rb.statut || 'paye', rb.notes || null,
|
||||
dossierWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -867,14 +877,15 @@ router.post('/dossier/apply', (req, res, next) => {
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
(investissement_id, date_remb, capital, cashback, interets_bruts,
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
|
||||
`).run(
|
||||
investissementId, rb.date_remb,
|
||||
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
|
||||
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
|
||||
rb.interets_nets || 0, rb.net_recu || 0,
|
||||
rb.statut || 'paye', rb.notes || null,
|
||||
dossierWorkspaceId,
|
||||
);
|
||||
rembInserted++;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -4,6 +4,7 @@ import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { createZip, readZip } from '../utils/zip.js';
|
||||
import { deleteDocumentsForEntity } from './documents.js';
|
||||
import { getUserActiveWorkspaceIds } from '../middleware/workspaceScope.js';
|
||||
import multer from 'multer';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
@@ -65,6 +66,8 @@ const Schema = z.object({
|
||||
type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(),
|
||||
freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(),
|
||||
referentiel_id: z.number().int().positive().nullable().optional(),
|
||||
// Espaces de travail auxquels cette plateforme utilisateur est affectée (au moins un requis)
|
||||
workspace_ids: z.array(z.number().int()).min(1, 'Au moins un espace de travail doit être sélectionné'),
|
||||
});
|
||||
|
||||
// Champs hérités du référentiel (comparés pour calculer overridden_fields)
|
||||
@@ -223,6 +226,58 @@ function syncCategories(platId, catIds) {
|
||||
})(platId, catIds);
|
||||
}
|
||||
|
||||
/** Espaces de travail auxquels chaque plateforme UTILISATEUR est affectée */
|
||||
function attachWorkspaces(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = rows.map(r => r.id);
|
||||
const links = db.prepare(`
|
||||
SELECT pw.plateforme_id, w.id AS workspace_id, w.slug, w.nom
|
||||
FROM plateforme_workspaces pw
|
||||
JOIN workspaces w ON w.id = pw.workspace_id
|
||||
WHERE pw.plateforme_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY w.ordre, w.id
|
||||
`).all(...ids);
|
||||
const map = {};
|
||||
for (const l of links) {
|
||||
if (!map[l.plateforme_id]) map[l.plateforme_id] = [];
|
||||
map[l.plateforme_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
|
||||
}
|
||||
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Espaces de travail auxquels chaque entrée du RÉFÉRENTIEL est affectée (pour filtrer l'éligibilité) */
|
||||
function attachReferentielWorkspaces(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = rows.map(r => r.id);
|
||||
const links = db.prepare(`
|
||||
SELECT rw.referentiel_id, w.id AS workspace_id, w.slug, w.nom
|
||||
FROM referentiel_workspaces rw
|
||||
JOIN workspaces w ON w.id = rw.workspace_id
|
||||
WHERE rw.referentiel_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY w.ordre, w.id
|
||||
`).all(...ids);
|
||||
const map = {};
|
||||
for (const l of links) {
|
||||
if (!map[l.referentiel_id]) map[l.referentiel_id] = [];
|
||||
map[l.referentiel_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
|
||||
}
|
||||
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Workspaces que l'utilisateur a le droit d'utiliser : accordés par l'admin ET activés par lui-même
|
||||
* (Settings > Mes espaces de travail) — seuls ceux-là sont sélectionnables pour une NOUVELLE
|
||||
* affectation de plateforme. Une affectation déjà existante reste conservée même hors de cet
|
||||
* ensemble (cf. validation dans PUT /:id). */
|
||||
// getUserActiveWorkspaceIds : déplacé le 17/09/26 dans middleware/workspaceScope.js
|
||||
// (réutilisé désormais par depots_retraits/imports/remboursements, cf. import ci-dessus).
|
||||
|
||||
function syncWorkspaces(platId, workspaceIds) {
|
||||
db.prepare('DELETE FROM plateforme_workspaces WHERE plateforme_id = ?').run(platId);
|
||||
if (!workspaceIds || workspaceIds.length === 0) return;
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id) VALUES (?,?)');
|
||||
for (const id of workspaceIds) ins.run(platId, id);
|
||||
}
|
||||
|
||||
// ── Lecture référentiel (tous users authentifiés) ─────────────────────────
|
||||
router.get('/referentiel-list', (_req, res) => {
|
||||
const rows = db.prepare(`
|
||||
@@ -232,7 +287,9 @@ router.get('/referentiel-list', (_req, res) => {
|
||||
FROM plateformes_referentiel pr
|
||||
ORDER BY pr.nom
|
||||
`).all();
|
||||
res.json(rows);
|
||||
// workspaces attachés pour permettre au frontend de filtrer les entrées éligibles
|
||||
// à l'espace de travail choisi par l'utilisateur lors de l'ajout (PlatPickerModal)
|
||||
res.json(attachReferentielWorkspaces(rows));
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
@@ -261,16 +318,26 @@ router.get('/', (req, res) => {
|
||||
}));
|
||||
const withCats = attachCategories(req.user.id, enriched);
|
||||
const withCatsInv = attachCategoriesInv(withCats);
|
||||
const withAll = attachSecteursInv(withCatsInv).map(r => {
|
||||
const withStripped = attachSecteursInv(withCatsInv).map(r => {
|
||||
const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
const withAll = attachWorkspaces(withStripped);
|
||||
res.json(withAll);
|
||||
});
|
||||
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
|
||||
// On ne peut affecter, à la CRÉATION, qu'à des espaces de travail auxquels on a
|
||||
// accès et qu'on a activés (Settings > Mes espaces de travail).
|
||||
const allowedWsCreate = new Set(getUserActiveWorkspaceIds(req.user.id));
|
||||
const invalidWsCreate = body.workspace_ids.filter(id => !allowedWsCreate.has(id));
|
||||
if (invalidWsCreate.length > 0) {
|
||||
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
|
||||
}
|
||||
|
||||
let fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite;
|
||||
let taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null;
|
||||
let typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT';
|
||||
@@ -305,6 +372,7 @@ router.post('/', (req, res, next) => {
|
||||
);
|
||||
const id = r.lastInsertRowid;
|
||||
syncCategories(id, body.categories);
|
||||
syncWorkspaces(id, body.workspace_ids);
|
||||
res.status(201).json({
|
||||
id, ...body,
|
||||
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
|
||||
@@ -335,6 +403,7 @@ router.get('/export', (req, res, next) => {
|
||||
`).all(req.user.id);
|
||||
rows = attachCategoriesInv(rows);
|
||||
rows = attachSecteursInv(rows);
|
||||
rows = attachWorkspaces(rows);
|
||||
|
||||
// Attach legacy categories
|
||||
const platIds = rows.map(r => r.id);
|
||||
@@ -377,6 +446,7 @@ router.get('/export', (req, res, next) => {
|
||||
categories: legacyMap[r.id] || [],
|
||||
categories_inv: (r.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (r.workspaces || []).map(w => w.slug),
|
||||
}));
|
||||
|
||||
entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) });
|
||||
@@ -417,7 +487,8 @@ router.get('/:id/export', (req, res, next) => {
|
||||
if (!p) throw new HttpError(404, 'Plateforme introuvable');
|
||||
|
||||
const [withCatsInv] = attachCategoriesInv([p]);
|
||||
const [withAll] = attachSecteursInv([withCatsInv]);
|
||||
const [withSects] = attachSecteursInv([withCatsInv]);
|
||||
const [withAll] = attachWorkspaces([withSects]);
|
||||
|
||||
const catsLegacy = db.prepare(`
|
||||
SELECT c.nom FROM plateforme_categories pc
|
||||
@@ -443,6 +514,7 @@ router.get('/:id/export', (req, res, next) => {
|
||||
categories: catsLegacy,
|
||||
categories_inv: (withAll.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (withAll.workspaces || []).map(w => w.slug),
|
||||
};
|
||||
entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) });
|
||||
|
||||
@@ -541,6 +613,25 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Résoudre des slugs de workspaces vers leurs IDs — restreint à ceux auxquels
|
||||
// l'utilisateur important a effectivement accès (accordé + activé). Un slug
|
||||
// inconnu ou hors d'accès est ignoré ; si la liste résultante est vide (vieux ZIP
|
||||
// sans workspace_slugs, ou accès différent), fallback sur "crowdlending" si accessible,
|
||||
// pour ne jamais importer une plateforme sans aucune affectation.
|
||||
const userAllowedWs = new Set(getUserActiveWorkspaceIds(req.user.id));
|
||||
function resolveWorkspaceIdsForUser(slugs) {
|
||||
const ids = [];
|
||||
for (const slug of (slugs || [])) {
|
||||
const row = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(slug);
|
||||
if (row && userAllowedWs.has(row.id)) ids.push(row.id);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (cl && userAllowedWs.has(cl.id)) ids.push(cl.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
const imageMap = {};
|
||||
for (const e of zipEntries) {
|
||||
if (e.name.startsWith('logos/') && e.name.length > 6) imageMap[path.basename(e.name)] = e.data;
|
||||
@@ -557,6 +648,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv');
|
||||
const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
|
||||
const legacyCatIds = resolveLegacyCatIds(p.categories);
|
||||
const wsIds = resolveWorkspaceIdsForUser(p.workspace_slugs);
|
||||
|
||||
const existing = db.prepare('SELECT id FROM plateformes WHERE nom = ? AND user_id = ?').get(p.nom, req.user.id);
|
||||
let platId;
|
||||
@@ -601,6 +693,8 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
// Sync secteurs_inv
|
||||
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(platId);
|
||||
for (const id of sectsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?,?)').run(platId, id);
|
||||
// Sync workspaces
|
||||
syncWorkspaces(platId, wsIds);
|
||||
|
||||
// Images
|
||||
for (const fname of [p.logo_filename, p.icone_filename]) {
|
||||
@@ -627,6 +721,18 @@ router.put('/:id', (req, res, next) => {
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!current) throw new HttpError(404, 'Not found');
|
||||
|
||||
// Validation des workspaces : on ne peut AJOUTER que des workspaces auxquels on a
|
||||
// accès et qu'on a activés (Settings > Mes espaces de travail) ; une affectation déjà
|
||||
// présente reste autorisée même si l'accès a depuis été retiré (jamais retirée
|
||||
// silencieusement par cette route — seul l'utilisateur peut la décocher lui-même).
|
||||
const currentWsIds = db.prepare('SELECT workspace_id FROM plateforme_workspaces WHERE plateforme_id = ?')
|
||||
.all(req.params.id).map(r => r.workspace_id);
|
||||
const allowedWsEdit = new Set([...getUserActiveWorkspaceIds(req.user.id), ...currentWsIds]);
|
||||
const invalidWsEdit = body.workspace_ids.filter(id => !allowedWsEdit.has(id));
|
||||
if (invalidWsEdit.length > 0) {
|
||||
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
|
||||
}
|
||||
|
||||
// Calculer overridden_fields si la plateforme est liée à un référentiel
|
||||
let overriddenFields = JSON.parse(current.overridden_fields || '[]');
|
||||
if (current.referentiel_id) {
|
||||
@@ -664,6 +770,7 @@ router.put('/:id', (req, res, next) => {
|
||||
);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||
syncCategories(Number(req.params.id), body.categories);
|
||||
syncWorkspaces(Number(req.params.id), body.workspace_ids);
|
||||
res.json({
|
||||
id: Number(req.params.id), ...body,
|
||||
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
|
||||
|
||||
@@ -20,7 +20,7 @@ const ALLOWED_KEYS = new Set([
|
||||
// Contrairement aux autres clés de ALLOWED_KEYS, la valeur est validée ici
|
||||
// (pas seulement la clé) car une valeur invalide casserait silencieusement
|
||||
// le rendu CSS ([data-color-theme] côté frontend n'a pas de fallback).
|
||||
const COLOR_THEME_VALUES = new Set([
|
||||
export const COLOR_THEME_VALUES = new Set([
|
||||
'classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil',
|
||||
'violet', 'frais', 'contraste', 'pastel', 'moderne',
|
||||
]);
|
||||
|
||||
@@ -110,6 +110,8 @@ const Schema = z.object({
|
||||
// Nouvelles listes gérées globalement (IDs)
|
||||
categories_inv_ids: z.array(z.number().int()).optional().default([]),
|
||||
secteurs_inv_ids: z.array(z.number().int()).optional().default([]),
|
||||
// Espaces de travail auxquels ce référentiel est affecté (au moins un requis)
|
||||
workspace_ids: z.array(z.number().int()).min(1, 'Au moins un espace de travail doit être sélectionné'),
|
||||
// Notation : tableau de critères
|
||||
notation: z.array(z.object({
|
||||
nom: z.string().min(1),
|
||||
@@ -181,6 +183,25 @@ function attachSecteursInv(rows) {
|
||||
return rows.map(r => ({ ...r, secteurs_inv: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Espaces de travail auxquels le référentiel est affecté */
|
||||
function attachWorkspaces(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = rows.map(r => r.id);
|
||||
const links = db.prepare(`
|
||||
SELECT rw.referentiel_id, w.id AS workspace_id, w.slug, w.nom
|
||||
FROM referentiel_workspaces rw
|
||||
JOIN workspaces w ON w.id = rw.workspace_id
|
||||
WHERE rw.referentiel_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY w.ordre, w.id
|
||||
`).all(...ids);
|
||||
const map = {};
|
||||
for (const l of links) {
|
||||
if (!map[l.referentiel_id]) map[l.referentiel_id] = [];
|
||||
map[l.referentiel_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
|
||||
}
|
||||
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Critères de notation du référentiel */
|
||||
function attachNotation(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
@@ -213,7 +234,7 @@ function parsePaysOp(rows) {
|
||||
}
|
||||
|
||||
/** Enregistre catégories, secteurs et notation pour un référentiel */
|
||||
function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds) {
|
||||
function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds, workspaceIds) {
|
||||
tx(() => {
|
||||
// Legacy string categories
|
||||
db.prepare('DELETE FROM referentiel_categories WHERE referentiel_id = ?').run(refId);
|
||||
@@ -238,6 +259,13 @@ function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteu
|
||||
for (const id of secteursInvIds) ins.run(refId, id);
|
||||
}
|
||||
|
||||
// Espaces de travail (IDs) — au moins un requis, déjà validé par le schéma Zod
|
||||
db.prepare('DELETE FROM referentiel_workspaces WHERE referentiel_id = ?').run(refId);
|
||||
if (workspaceIds && workspaceIds.length > 0) {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id) VALUES (?,?)');
|
||||
for (const id of workspaceIds) ins.run(refId, id);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM referentiel_notation WHERE referentiel_id = ?').run(refId);
|
||||
if (notation.length > 0) {
|
||||
const ins = db.prepare(`
|
||||
@@ -267,6 +295,7 @@ router.get('/', (_req, res) => {
|
||||
rows = attachCats(rows);
|
||||
rows = attachCatsInv(rows);
|
||||
rows = attachSecteursInv(rows);
|
||||
rows = attachWorkspaces(rows);
|
||||
rows = attachNotation(rows);
|
||||
rows = parsePaysOp(rows);
|
||||
res.json(rows);
|
||||
@@ -288,6 +317,7 @@ router.get('/export', (req, res, next) => {
|
||||
let rows = db.prepare('SELECT * FROM plateformes_referentiel ORDER BY nom').all();
|
||||
rows = attachCatsInv(rows);
|
||||
rows = attachSecteursInv(rows);
|
||||
rows = attachWorkspaces(rows);
|
||||
rows = parsePaysOp(rows);
|
||||
|
||||
const entries = [];
|
||||
@@ -353,6 +383,7 @@ router.get('/export', (req, res, next) => {
|
||||
icone_filename: r.icone_filename,
|
||||
categories_inv: (r.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (r.workspaces || []).map(w => w.slug),
|
||||
notation: notationByRef[r.id] || [],
|
||||
}));
|
||||
|
||||
@@ -388,7 +419,7 @@ router.get('/:id/export', (req, res, next) => {
|
||||
try {
|
||||
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
|
||||
const [enriched] = parsePaysOp(attachSecteursInv(attachCatsInv([ref])));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachSecteursInv(attachCatsInv([ref]))));
|
||||
|
||||
const entries = [];
|
||||
const manifest = {
|
||||
@@ -450,6 +481,7 @@ router.get('/:id/export', (req, res, next) => {
|
||||
icone_filename: enriched.icone_filename,
|
||||
categories_inv: (enriched.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (enriched.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (enriched.workspaces || []).map(w => w.slug),
|
||||
notation,
|
||||
};
|
||||
|
||||
@@ -506,6 +538,25 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Résout des slugs de workspaces vers leurs IDs DANS CE catalogue —
|
||||
// contrairement aux catégories/secteurs, les workspaces sont un
|
||||
// catalogue géré par l'admin, jamais créés automatiquement à l'import.
|
||||
// Si aucun slug ne correspond (vieux ZIP sans workspace_slugs, ou
|
||||
// catalogue cible différent), on retombe sur "crowdlending" pour ne
|
||||
// jamais importer une entrée référentiel sans aucune affectation.
|
||||
function resolveWorkspaceIds(slugs) {
|
||||
const ids = [];
|
||||
for (const slug of (slugs || [])) {
|
||||
const row = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(slug);
|
||||
if (row) ids.push(row.id);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (cl) ids.push(cl.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Build a map of images from the ZIP
|
||||
const imageMap = {};
|
||||
for (const e of zipEntries) {
|
||||
@@ -522,6 +573,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
|
||||
const catsIds = resolveTagIds(p.categories_inv, 'categories_inv');
|
||||
const sectsIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
|
||||
const wsIds = resolveWorkspaceIds(p.workspace_slugs);
|
||||
const paysOp = Array.isArray(p.pays_operation) && p.pays_operation.length ? JSON.stringify(p.pays_operation) : null;
|
||||
|
||||
const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(p.nom);
|
||||
@@ -590,6 +642,11 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
for (const id of sectsIds) {
|
||||
db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)').run(refId, id);
|
||||
}
|
||||
// Sync referentiel_workspaces
|
||||
db.prepare('DELETE FROM referentiel_workspaces WHERE referentiel_id = ?').run(refId);
|
||||
for (const id of wsIds) {
|
||||
db.prepare('INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id) VALUES (?,?)').run(refId, id);
|
||||
}
|
||||
|
||||
// Sync referentiel_notation
|
||||
if (Array.isArray(p.notation) && p.notation.length) {
|
||||
@@ -659,7 +716,7 @@ router.get('/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Référentiel introuvable');
|
||||
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row])))));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))));
|
||||
res.json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
@@ -701,10 +758,10 @@ router.post('/', (req, res, next) => {
|
||||
|
||||
const refId = r.lastInsertRowid;
|
||||
const tx = db.transaction(fn => fn());
|
||||
saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids);
|
||||
saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids, data.workspace_ids);
|
||||
|
||||
const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(refId);
|
||||
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row])))));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))));
|
||||
enriched.nb_plateformes_liees = 0;
|
||||
res.status(201).json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
@@ -755,11 +812,11 @@ router.put('/:id', (req, res, next) => {
|
||||
);
|
||||
|
||||
const tx = db.transaction(fn => fn());
|
||||
saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids);
|
||||
saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids, data.workspace_ids);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(row.id);
|
||||
const nb = db.prepare('SELECT COUNT(*) AS n FROM plateformes WHERE referentiel_id = ?').get(row.id).n;
|
||||
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated])))));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated]))))));
|
||||
enriched.nb_plateformes_liees = nb;
|
||||
res.json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
||||
import { assertOwnedCompte } from './fraisOperations.js';
|
||||
|
||||
@@ -13,6 +14,12 @@ const Schema = z.object({
|
||||
investissement_id: z.number().int().positive().nullable().optional(),
|
||||
bonus_plateforme_id: z.number().int().positive().nullable().optional(),
|
||||
bonus_investisseur_id: z.number().int().positive().nullable().optional(),
|
||||
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
|
||||
// rattacher une distribution bonus_plateforme/interets_plateforme à un deal
|
||||
// précis pour calculer son TVPI, quand plusieurs deals partagent une même
|
||||
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
|
||||
// Sans effet sur le type 'normal' (crowdlending).
|
||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
||||
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
capital: z.number().nonnegative().default(0),
|
||||
cashback: z.number().nonnegative().default(0),
|
||||
@@ -89,7 +96,7 @@ function getTaxIndicatif(body) {
|
||||
* puis en insère un nouveau avec source='auto_remboursement'.
|
||||
* - Sinon : supprime simplement l'ancien retrait lié.
|
||||
*/
|
||||
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
|
||||
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb, req) {
|
||||
// Suppression du retrait automatique précédent (s'il y en avait un)
|
||||
db.prepare('DELETE FROM depots_retraits WHERE remboursement_id = ?').run(rembId);
|
||||
|
||||
@@ -100,16 +107,22 @@ function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
|
||||
).get(investissementId);
|
||||
if (!inv) return;
|
||||
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : le retrait auto généré par un
|
||||
// remboursement hérite du workspace actif au moment du remboursement (même
|
||||
// résolution que les dépôts/retraits manuels, cf. workspaceScope.js).
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant,
|
||||
libelle, source, remboursement_id)
|
||||
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?)
|
||||
libelle, source, remboursement_id, workspace_id)
|
||||
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?, ?)
|
||||
`).run(
|
||||
inv.investisseur_id, inv.plateforme_id, dateRemb,
|
||||
netRecu,
|
||||
`Remboursement — ${inv.nom_projet}`,
|
||||
rembId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +214,9 @@ router.get('/', (req, res) => {
|
||||
: `(i.investisseur_id = ?
|
||||
OR (r.investissement_id IS NULL AND r.bonus_investisseur_id = ?))`;
|
||||
const invArg = scopeAll ? req.user.id : req.investisseur.id;
|
||||
// Étape 2c du retrofit workspace_id (17/09/26) : filtre par workspace actif,
|
||||
// même principe que GET /depots-retraits.
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const outerConds = [];
|
||||
const outerArgs = [];
|
||||
@@ -228,7 +244,7 @@ router.get('/', (req, res) => {
|
||||
END AS capital_restant_du
|
||||
FROM remboursements r
|
||||
LEFT JOIN investissements i ON i.id = r.investissement_id
|
||||
WHERE ${invCond}
|
||||
WHERE ${invCond} AND r.workspace_id = ?
|
||||
)
|
||||
SELECT
|
||||
b.*,
|
||||
@@ -254,7 +270,7 @@ router.get('/', (req, res) => {
|
||||
LEFT JOIN comptes c ON c.id = b.compte_id
|
||||
${outerWhere}
|
||||
ORDER BY b.date_remb DESC, b.id DESC
|
||||
`).all(invArg, invArg, ...outerArgs);
|
||||
`).all(invArg, invArg, workspaceId, ...outerArgs);
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -271,18 +287,19 @@ router.post('/', (req, res, next) => {
|
||||
INSERT INTO remboursements
|
||||
(type, investissement_id, date_remb, capital, cashback,
|
||||
interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc, prelev_sociaux,
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id)
|
||||
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?)
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id, workspace_id)
|
||||
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?, ?)
|
||||
`).run(
|
||||
body.investissement_id, body.date_remb, body.capital, body.cashback,
|
||||
body.interets_bruts_avant_local, body.taxe_locale,
|
||||
body.interets_bruts, body.frais_ttc, body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
|
||||
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
|
||||
resolveActiveWorkspaceId(req),
|
||||
);
|
||||
syncInvestissementStatut(body.investissement_id);
|
||||
adjustSimulForActuals(db, body.investissement_id);
|
||||
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
||||
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
|
||||
{
|
||||
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
|
||||
syncFraisDistribution(
|
||||
@@ -320,13 +337,14 @@ router.post('/', (req, res, next) => {
|
||||
INSERT INTO remboursements
|
||||
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
||||
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes)
|
||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?)
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
|
||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?)
|
||||
`).run(
|
||||
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
||||
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null,
|
||||
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
|
||||
);
|
||||
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
|
||||
}
|
||||
@@ -335,11 +353,12 @@ router.post('/', (req, res, next) => {
|
||||
INSERT INTO remboursements
|
||||
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
||||
capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire,
|
||||
interets_nets, net_recu, statut, source, notes)
|
||||
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?)
|
||||
interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
|
||||
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?, ?, ?)
|
||||
`).run(
|
||||
body.type, body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
||||
body.cashback, body.cashback, body.statut, body.notes || null,
|
||||
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
|
||||
);
|
||||
res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets: 0, net_recu: body.cashback });
|
||||
} catch (e) { next(e); }
|
||||
@@ -481,7 +500,7 @@ router.put('/:id', (req, res, next) => {
|
||||
);
|
||||
syncInvestissementStatut(body.investissement_id);
|
||||
adjustSimulForActuals(db, body.investissement_id);
|
||||
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
||||
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
|
||||
{
|
||||
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
|
||||
syncFraisDistribution(
|
||||
@@ -493,20 +512,21 @@ router.put('/:id', (req, res, next) => {
|
||||
db.prepare(`
|
||||
UPDATE remboursements
|
||||
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
|
||||
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?
|
||||
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
|
||||
investissement_pe_id=?
|
||||
WHERE id=?
|
||||
`).run(
|
||||
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, id,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, body.investissement_pe_id ?? null, id,
|
||||
);
|
||||
} else {
|
||||
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
|
||||
db.prepare(`
|
||||
UPDATE remboursements
|
||||
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?
|
||||
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?, investissement_pe_id=?
|
||||
WHERE id=?
|
||||
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, id);
|
||||
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, body.investissement_pe_id ?? null, id);
|
||||
}
|
||||
|
||||
res.json({ id, ...body, interets_nets, net_recu });
|
||||
@@ -526,7 +546,7 @@ router.delete('/:id', (req, res, next) => {
|
||||
`).get(id, req.user.id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
|
||||
syncAutoRetrait(id, 'portefeuille', 0, null, null); // supprime le retrait auto si présent
|
||||
syncAutoRetrait(id, 'portefeuille', 0, null, null, req); // supprime le retrait auto si présent
|
||||
db.prepare('DELETE FROM remboursements WHERE id=?').run(id);
|
||||
if (existing.investissement_id) {
|
||||
syncInvestissementStatut(existing.investissement_id);
|
||||
@@ -545,7 +565,7 @@ router.delete('/', (req, res, next) => {
|
||||
|
||||
const rembs = db.prepare('SELECT id FROM remboursements WHERE investissement_id=?').all(investissement_id);
|
||||
for (const r of rembs) {
|
||||
syncAutoRetrait(r.id, 'portefeuille', 0, null, null);
|
||||
syncAutoRetrait(r.id, 'portefeuille', 0, null, null, req);
|
||||
}
|
||||
db.prepare('DELETE FROM remboursements WHERE investissement_id=?').run(investissement_id);
|
||||
syncInvestissementStatut(investissement_id);
|
||||
|
||||
@@ -10,8 +10,17 @@ const router = Router();
|
||||
* summary: Liste des mouvements de cash (dépôts / retraits)
|
||||
* tags: [Dépôts / Retraits]
|
||||
* security: [{ ApiKeyAuth: [] }]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: workspace
|
||||
* schema: { type: string }
|
||||
* description: >
|
||||
* Slug du workspace à filtrer (ex. "crowdlending"). Absent par défaut :
|
||||
* renvoie tous les workspaces confondus (les clés API restent globales,
|
||||
* cf. project_workspaces_transformation.md) — 400 si le slug est inconnu.
|
||||
* responses:
|
||||
* 200: { description: Liste des mouvements }
|
||||
* 400: { description: Slug de workspace inconnu }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
|
||||
@@ -21,14 +30,29 @@ router.get('/', (req, res) => {
|
||||
: 'dr.investisseur_id = ?';
|
||||
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
|
||||
|
||||
const conds = [invCond];
|
||||
const args = [invParam];
|
||||
|
||||
// Filtre optionnel par workspace (17/09/26, décision explicite d'Olivier) :
|
||||
// absent par défaut → comportement inchangé, tous workspaces confondus (les
|
||||
// clés API/MCP restent globales, décision du 08/09/26) ; ?workspace=<slug>
|
||||
// permet à un outil MCP de cibler un workspace précis — slug plutôt qu'un id
|
||||
// brut, portable entre installations comme pour l'export/import ZIP.
|
||||
if (req.query.workspace) {
|
||||
const ws = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
|
||||
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
|
||||
conds.push('dr.workspace_id = ?');
|
||||
args.push(ws.id);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
|
||||
dr.montant, dr.libelle
|
||||
FROM depots_retraits dr
|
||||
JOIN plateformes p ON p.id = dr.plateforme_id
|
||||
WHERE ${invCond}
|
||||
WHERE ${conds.join(' AND ')}
|
||||
ORDER BY dr.date_operation DESC
|
||||
`).all(invParam);
|
||||
`).all(...args);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,16 @@ const router = Router();
|
||||
* - in: query
|
||||
* name: date_fin
|
||||
* schema: { type: string, format: date }
|
||||
* - in: query
|
||||
* name: workspace
|
||||
* schema: { type: string }
|
||||
* description: >
|
||||
* Slug du workspace à filtrer (ex. "crowdlending"). Absent par défaut :
|
||||
* renvoie tous les workspaces confondus (les clés API restent globales,
|
||||
* cf. project_workspaces_transformation.md) — 400 si le slug est inconnu.
|
||||
* responses:
|
||||
* 200: { description: Liste des remboursements }
|
||||
* 400: { description: Slug de workspace inconnu }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const { date_debut, date_fin } = req.query;
|
||||
@@ -37,6 +45,15 @@ router.get('/', (req, res) => {
|
||||
if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
|
||||
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
|
||||
|
||||
// Filtre optionnel par workspace (17/09/26, même principe que /depots-retraits) :
|
||||
// absent par défaut → comportement inchangé, tous workspaces confondus.
|
||||
if (req.query.workspace) {
|
||||
const ws = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
|
||||
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
|
||||
conds.push('r.workspace_id = ?');
|
||||
args.push(ws.id);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
|
||||
r.capital, r.interets_bruts, r.frais_ttc,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { COLOR_THEME_VALUES } from './preferences.js';
|
||||
|
||||
/* ── Espaces de travail de l'utilisateur connecté ─────────────────────────
|
||||
* Liste les workspaces accordés par l'admin (granted_by_admin=1) avec leur
|
||||
@@ -14,7 +15,7 @@ const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user
|
||||
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user, uw.color_theme
|
||||
FROM user_workspaces uw
|
||||
JOIN workspaces w ON w.id = uw.workspace_id
|
||||
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND w.actif_global = 1
|
||||
@@ -43,4 +44,34 @@ router.patch('/:id/active', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Couleur d'accent personnelle pour cet espace de travail (17/09/26) — cf.
|
||||
// project_workspaces_transformation.md et migration user_workspaces.color_theme
|
||||
// (db/index.js). null = hérite de la couleur de l'application (UiContext.jsx,
|
||||
// colorTheme || adminColorTheme). Même schéma de validation que PATCH
|
||||
// /preferences (color_theme) : valeur vérifiée ici (pas seulement la clé),
|
||||
// une valeur invalide casserait silencieusement le rendu CSS.
|
||||
const ColorSchema = z.object({ color_theme: z.string().nullable() });
|
||||
|
||||
router.patch('/:id/color', (req, res, next) => {
|
||||
try {
|
||||
const { color_theme } = ColorSchema.parse(req.body);
|
||||
const workspaceId = Number(req.params.id);
|
||||
|
||||
if (color_theme !== null && !COLOR_THEME_VALUES.has(color_theme)) {
|
||||
throw new HttpError(400, `Valeur invalide pour color_theme : ${color_theme}`);
|
||||
}
|
||||
|
||||
const grant = db.prepare(
|
||||
'SELECT 1 FROM user_workspaces WHERE user_id = ? AND workspace_id = ? AND granted_by_admin = 1'
|
||||
).get(req.user.id, workspaceId);
|
||||
if (!grant) throw new HttpError(403, "Vous n'avez pas accès à cet espace de travail");
|
||||
|
||||
db.prepare(
|
||||
'UPDATE user_workspaces SET color_theme = ? WHERE user_id = ? AND workspace_id = ?'
|
||||
).run(color_theme, req.user.id, workspaceId);
|
||||
|
||||
res.json({ id: workspaceId, color_theme });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in new issue
Block a user