Implementation de la notion d'espace d'investissement

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

No files matched your search

+259
View File
@@ -3069,6 +3069,22 @@ db.exec(`
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(user_id)');
// ── Migration : couleur de thème par (utilisateur, workspace) (17/09/26) ──
// Permet à l'utilisateur de personnaliser la couleur d'accent par espace de
// travail (demande Olivier) : NULL = hérite de la couleur de l'application
// (personnelle si définie via user_preferences.color_theme, sinon défaut
// admin) — même énumération que color_theme (cf. COLOR_THEME_VALUES,
// routes/preferences.js). Stocké sur user_workspaces (PK user_id+
// workspace_id) plutôt que sur workspaces : c'est un réglage personnel par
// compte, pas un réglage global de l'espace. Cf. AppearanceSection.jsx,
// WorkspaceContext.jsx et UiContext.jsx (workspaceColorOverride).
{
const uwCols = db.prepare('PRAGMA table_info(user_workspaces)').all().map(c => c.name);
if (!uwCols.includes('color_theme')) {
db.exec('ALTER TABLE user_workspaces ADD COLUMN color_theme TEXT');
}
}
// Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE).
db.prepare(`
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
@@ -3103,4 +3119,247 @@ export function grantDefaultWorkspace(userId) {
}
}
// ── Migration : Workspaces sur le référentiel plateformes (08/09/26) ─────
// Chaque entrée du référentiel (plateformes_referentiel) est désormais
// rattachée à un ou plusieurs workspaces (relation N-N, même patron que
// referentiel_categories_inv/referentiel_secteurs_inv). Demande d'Olivier :
// réaffectation obligatoire vers "crowdlending" pour ce qui existe déjà en
// base, et au moins une affectation exigée à la création (validé côté API
// par le schéma Zod de routes/referentiel.js, min(1) sur workspace_ids).
// Placé ici (après la création de `workspaces` ci-dessus) pour garantir
// que la table existe déjà. Cf. project_workspaces_transformation.md.
db.exec(`
CREATE TABLE IF NOT EXISTS referentiel_workspaces (
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
PRIMARY KEY (referentiel_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ref_workspaces_ws ON referentiel_workspaces(workspace_id)');
// Backfill idempotent : toute entrée référentiel sans aucune affectation
// (comptes créés avant cette migration, ou entrées jamais touchées depuis)
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id)
SELECT pr.id, ?
FROM plateformes_referentiel pr
WHERE NOT EXISTS (SELECT 1 FROM referentiel_workspaces rw WHERE rw.referentiel_id = pr.id)
`).run(wsCrowdlending.id);
}
}
// ── Migration : Workspaces sur les plateformes utilisateur (08/09/26) ────
// Extension du même principe que le référentiel ci-dessus : chaque
// plateforme UTILISATEUR (table `plateformes`, distincte du référentiel
// admin) peut désormais être rattachée à un ou plusieurs workspaces (N-N,
// même patron `xxx_workspaces`). Décision d'Olivier (AskUserQuestion,
// 08/09/26) : un compte sur une plateforme peut servir à la fois au
// crowdlending et à un futur workspace Private Equity — pas de colonne
// unique comme initialement envisagé pour l'Étape 2 du retrofit général.
// Backfill : toute plateforme déjà en base est rattachée à "crowdlending".
// Cf. project_workspaces_transformation.md.
db.exec(`
CREATE TABLE IF NOT EXISTS plateforme_workspaces (
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
PRIMARY KEY (plateforme_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_plat_workspaces_ws ON plateforme_workspaces(workspace_id)');
// Backfill idempotent : toute plateforme utilisateur sans aucune affectation
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id)
SELECT p.id, ?
FROM plateformes p
WHERE NOT EXISTS (SELECT 1 FROM plateforme_workspaces pw WHERE pw.plateforme_id = p.id)
`).run(wsCrowdlending.id);
}
}
// ── Migration : workspace_id sur depots_retraits (17/09/26) ─────────────
// Contrairement à plateformes/plateformes_referentiel (relation N-N — un compte
// pouvant servir plusieurs workspaces à la fois), Olivier a tranché le 17/09/26
// pour une colonne UNIQUE sur depots_retraits : chaque mouvement de trésorerie
// est "flêché" vers un seul workspace précis. Démarre, table par table, l'Étape 1
// du retrofit workspace_id documenté dans project_workspaces_transformation.md —
// dépôts/retraits en premier, à la demande explicite d'Olivier (remboursements
// ensuite). Colonne nullable, backfill de l'existant vers "crowdlending".
{
const drCols2 = db.prepare('PRAGMA table_info(depots_retraits)').all().map(c => c.name);
if (!drCols2.includes('workspace_id')) {
db.exec('ALTER TABLE depots_retraits ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_depret_workspace ON depots_retraits(workspace_id)');
// Backfill idempotent : tout dépôt/retrait sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending2) {
db.prepare('UPDATE depots_retraits SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending2.id);
}
}
// ── Migration : workspace_id sur remboursements (17/09/26) ──────────────
// Suite du retrofit workspace_id (project_workspaces_transformation.md),
// table par table à la demande d'Olivier : après depots_retraits, les
// remboursements. Même principe : colonne unique nullable, backfill de
// l'existant vers "crowdlending".
{
const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols.includes('workspace_id')) {
db.exec('ALTER TABLE remboursements ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_workspace ON remboursements(workspace_id)');
// Backfill idempotent : tout remboursement sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending3) {
db.prepare('UPDATE remboursements SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending3.id);
}
}
// ── Investissements Private Equity (17/09/26) ────────────────────────────
// Nouveau modèle de données, volontairement séparé de `investissements`
// (cf. project_workspaces_transformation.md) : un deal PE (ex. Fundora) n'a
// ni échéancier connu à l'avance, ni taux d'intérêt fixe — c'est un montant
// unique investi (même principe que le crowdlending, décision Olivier
// 17/09/26 : le porte-monnaie diminue d'un coup, pas de suivi souscrit/
// décaissé), valorisé périodiquement par le fonds, avec un objectif de
// performance hétérogène selon les deals (multiple "x4,5" ou fourchette de
// taux annuel "9-12 %/an") — on le stocke donc en texte libre plutôt que
// d'essayer de le structurer. Table neuve : workspace_id est NOT NULL dès
// la création, pas besoin du nullable+backfill utilisé pour les tables
// préexistantes (depots_retraits, remboursements).
db.exec(`
CREATE TABLE IF NOT EXISTS investissements_pe (
id INTEGER PRIMARY KEY,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
nom_deal TEXT NOT NULL,
strategie TEXT, -- texte libre : 'Capital développement', 'Capital risque', 'Dette'...
date_souscription TEXT NOT NULL,
duree_mois INTEGER,
montant_investi REAL NOT NULL CHECK(montant_investi > 0),
objectif TEXT, -- texte libre : "x4,5" ou "9 % - 12 %/an"
statut TEXT NOT NULL DEFAULT 'valide'
CHECK(statut IN ('en_attente','valide','cloture','perte_definitive')),
reference TEXT,
source TEXT NOT NULL DEFAULT 'manuel',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_inv ON investissements_pe(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_plat ON investissements_pe(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_ws ON investissements_pe(workspace_id)');
// ── Migration : frais PE — investissement_pe_id (17/09/26) ─────────────────
// frais_operations était exclusivement rattachée à investissements (crowdlending).
// Pour permettre la traçabilité des frais des deals PE (investissements_pe), on ajoute
// une colonne investissement_pe_id nullable, on desserre investissement_id (désormais
// nullable lui aussi), et on impose par CHECK qu'exactement une des deux références
// soit renseignée. Reconstruction de table (SQLite ne permet pas d'altérer une
// contrainte NOT NULL ni un CHECK en place) — même stratégie que les migrations
// précédentes de frais_operations, cf. plus haut dans ce fichier.
{
const fraisOpColsPe = db.prepare('PRAGMA table_info(frais_operations)').all().map(c => c.name);
if (!fraisOpColsPe.includes('investissement_pe_id')) {
const tempName = '__repair_frais_operations_pe';
const fixedDdl = `
CREATE TABLE "${tempName}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE,
investissement_pe_id INTEGER REFERENCES investissements_pe(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
remboursement_id INTEGER REFERENCES remboursements(id) ON DELETE CASCADE,
montant REAL NOT NULL,
mode_reglement TEXT NOT NULL DEFAULT 'source'
CHECK(mode_reglement IN ('source','portefeuille','compte_courant','remboursement')),
compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL,
date_operation TEXT NOT NULL,
origine TEXT NOT NULL DEFAULT 'manuel'
CHECK(origine IN ('souscription','remboursement','manuel')),
categorie TEXT NOT NULL DEFAULT 'autre',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK ((investissement_id IS NOT NULL) + (investissement_pe_id IS NOT NULL) = 1)
)
`;
const colDefs = db.prepare('PRAGMA table_info(frais_operations)').all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='frais_operations' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM frais_operations`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE frais_operations');
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO frais_operations`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv_pe ON frais_operations(investissement_pe_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
}
}
// Historique des valorisations (relevés périodiques du fonds) — une ligne par
// date de valorisation, plutôt qu'un champ unique écrasé à chaque mise à jour,
// pour permettre un graphique d'évolution et un TVPI recalculable à toute date
// passée (décision Olivier 17/09/26).
db.exec(`
CREATE TABLE IF NOT EXISTS investissements_pe_valorisations (
id INTEGER PRIMARY KEY,
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
date_valorisation TEXT NOT NULL,
valorisation_nette REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)');
// 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
// Olivier ("comme un remboursement, page déjà construite") — ce lien
// supplémentaire permet de rattacher cette distribution à un deal précis
// (indispensable quand plusieurs deals partagent une même plateforme, ex.
// Fundora) afin de calculer un TVPI par deal plutôt que par plateforme.
// Nullable et sans effet sur les remboursements crowdlending existants.
{
const rembCols2 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols2.includes('investissement_pe_id')) {
db.exec('ALTER TABLE remboursements ADD COLUMN investissement_pe_id INTEGER REFERENCES investissements_pe(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissement_pe_id)');
export default db;
+37
View File
@@ -0,0 +1,37 @@
import db from '../db/index.js';
/**
* Résout l'ensemble des workspace_id qu'un utilisateur a le droit d'utiliser :
* accordés par l'admin (granted_by_admin) ET activés par l'utilisateur (active_by_user).
* Miroir serveur exact de useWorkspace().activeWorkspaces côté frontend
* (frontend/src/context/WorkspaceContext.jsx). Centralisé ici (17/09/26) car utilisé
* par plusieurs routes (plateformes, depots_retraits, imports, remboursements, ...).
*/
export function getUserActiveWorkspaceIds(userId) {
return db.prepare(`
SELECT w.id
FROM user_workspaces uw
JOIN workspaces w ON w.id = uw.workspace_id
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND uw.active_by_user = 1 AND w.actif_global = 1
`).all(userId).map(r => r.id);
}
/**
* Résout le workspace_id "actif" à stamper sur une ligne créée côté serveur (dépôt/retrait
* pour l'instant — Étape 2a du retrofit workspace_id, cf. project_workspaces_transformation.md).
* Lu depuis le header X-Workspace-Id, envoyé automatiquement par le frontend
* (frontend/src/api.js, miroir de X-Investisseur-Id) et validé contre les workspaces actifs
* de l'utilisateur. Si le header est absent, invalide, ou non autorisé (ex. appel API v1/MCP
* qui ne l'envoie pas encore), retombe silencieusement sur le workspace "crowdlending" —
* jamais d'échec bloquant, pour rester transparent tant qu'un seul workspace existe réellement.
*/
export function resolveActiveWorkspaceId(req) {
const raw = req.header('X-Workspace-Id');
const id = Number(raw);
if (id) {
const allowed = new Set(getUserActiveWorkspaceIds(req.user.id));
if (allowed.has(id)) return id;
}
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
return cl ? cl.id : null;
}
+7 -1
View File
@@ -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);
+23
View File
@@ -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;
+216
View File
@@ -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;
+13 -3
View File
@@ -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); }
+12
View File
@@ -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) {
+104 -54
View File
@@ -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();
+19 -8
View File
@@ -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++;
}
+289
View File
@@ -0,0 +1,289 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
const router = Router();
/**
* Investissements Private Equity — table dédiée (17/09/26, cf.
* project_workspaces_transformation.md), distincte de `investissements`
* (crowdlending) : un deal PE n'a ni échéancier connu à l'avance ni taux
* d'intérêt fixe, c'est un montant unique investi, valorisé périodiquement
* par le fonds (investissements_pe_valorisations), avec un objectif de
* performance hétérogène selon les deals (texte libre plutôt que structuré).
*
* workspace_id est NOT NULL dès la création (table neuve, pas de legacy à
* backfiller) — toutes les lignes appartiennent au workspace actif au
* moment de la création, et toute lecture/écriture est bornée au workspace
* actif via resolveActiveWorkspaceId (même principe que depots_retraits et
* remboursements).
*/
const Schema = z.object({
investisseur_id: z.number().int().positive().optional(),
plateforme_id: z.number().int().positive(),
nom_deal: z.string().min(1),
strategie: z.string().optional(),
date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
duree_mois: z.number().int().positive().nullable().optional(),
montant_investi: z.number().positive(),
objectif: z.string().optional(),
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
reference: z.string().optional(),
notes: z.string().optional(),
});
const ValorisationSchema = z.object({
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
valorisation_nette: z.number().nonnegative(),
notes: z.string().optional(),
});
/** Résout l'investisseur_id : body en priorité (validé), sinon header (comme depots_retraits.js) */
function resolveInvestisseurId(req, bodyInvestisseurId) {
if (!bodyInvestisseurId) return req.investisseur.id;
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(bodyInvestisseurId, req.user.id);
if (!row) throw new HttpError(403, 'Investisseur non autorisé');
return bodyInvestisseurId;
}
/** Vérifie qu'un deal appartient bien à l'utilisateur courant (et au workspace actif), le renvoie. */
function ownedDeal(req, id) {
const row = db.prepare(`
SELECT ipe.* FROM investissements_pe ipe
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ipe.id = ? AND inv.user_id = ? AND ipe.workspace_id = ?
`).get(id, req.user.id, resolveActiveWorkspaceId(req));
if (!row) throw new HttpError(404, 'Not found');
return row;
}
/**
* GET /api/investissements-pe
* ?scope=all → agrège tous les investisseurs de l'utilisateur (vue "Famille")
* (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id
*/
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, invArgs;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'ipe.investisseur_id = ?';
invArgs = [id];
}
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT ipe.*,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
(SELECT v.date_valorisation FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...invArgs, workspaceId);
const withTvpi = rows.map(r => ({
...r,
tvpi: r.derniere_valorisation != null
? Math.round(((r.derniere_valorisation + r.distributions_cumulees) / r.montant_investi) * 100) / 100
: null,
}));
res.json(withTvpi);
});
/**
* GET /api/investissements-pe/valorisations
* Historique complet des valorisations, tous deals confondus (workspace
* actif) — nécessaire pour reconstituer une courbe de valorisation du
* portefeuille (page Investissements PE : graphique "Valorisation totale"
* et onglet "Vision mensuelle"), ce que l'historique par deal seul
* (GET /:id/valorisations) ne permet pas sans un aller-retour par deal.
* ?scope=all → agrège tous les investisseurs de l'utilisateur (comme GET /).
*/
router.get('/valorisations', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, invArgs;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'ipe.investisseur_id = ?';
invArgs = [id];
}
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT v.id, v.investissement_pe_id, v.date_valorisation, v.valorisation_nette
FROM investissements_pe_valorisations v
JOIN investissements_pe ipe ON ipe.id = v.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY v.date_valorisation ASC, v.id ASC
`).all(...invArgs, workspaceId);
res.json(rows);
});
router.post('/', requireInvestisseur, (req, res, next) => {
try {
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
const workspaceId = resolveActiveWorkspaceId(req);
const r = db.prepare(`
INSERT INTO investissements_pe
(investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription,
duree_mois, montant_investi, objectif, statut, reference, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.objectif || null,
body.statut, body.reference || null, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
/**
* GET /api/investissements-pe/:id — fiche détaillée d'un deal (chantier "fiche détail PE",
* 17/09/26). Même enrichissement que GET / (dernière valorisation, distributions cumulées,
* TVPI) mais pour une seule ligne — évite de renvoyer toute la liste juste pour afficher une
* fiche. requireInvestisseur n'est pas utilisé ici (ownedDeal vérifie déjà la propriété par
* user_id + workspace actif, sans dépendre de l'investisseur sélectionné côté client — la fiche
* doit rester accessible même si l'utilisateur a changé de sélecteur détenteur entre-temps).
*/
router.get('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const row = db.prepare(`
SELECT ipe.*,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
(SELECT v.date_valorisation FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ipe.id = ?
`).get(id);
if (!row) throw new HttpError(404, 'Not found');
row.tvpi = row.derniere_valorisation != null
? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100
: null;
res.json(row);
} catch (e) { next(e); }
});
router.put('/:id', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
db.prepare(`
UPDATE investissements_pe
SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?,
duree_mois=?, montant_investi=?, objectif=?, statut=?, reference=?, notes=?,
updated_at=datetime('now')
WHERE id=?
`).run(
investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription,
body.duree_mois ?? null, body.montant_investi, body.objectif || null, body.statut,
body.reference || null, body.notes || null, id,
);
res.json({ id, ...body });
} catch (e) { next(e); }
});
router.delete('/:id', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
// Les distributions déjà saisies (remboursements liés) restent en base,
// simplement détachées du deal supprimé — jamais de perte de données
// financières pour libérer une contrainte de clé étrangère.
db.prepare('UPDATE remboursements SET investissement_pe_id = NULL WHERE investissement_pe_id = ?').run(id);
db.prepare('DELETE FROM investissements_pe WHERE id = ?').run(id);
res.status(204).end();
} catch (e) { next(e); }
});
/* ── Historique des valorisations ────────────────────────────── */
router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const rows = db.prepare(`
SELECT * FROM investissements_pe_valorisations
WHERE investissement_pe_id = ?
ORDER BY date_valorisation DESC, id DESC
`).all(id);
res.json(rows);
} catch (e) { next(e); }
});
router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const body = ValorisationSchema.parse(req.body);
const r = db.prepare(`
INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, notes)
VALUES (?,?,?,?)
`).run(id, body.date_valorisation, body.valorisation_nette, body.notes || null);
res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, ...body });
} catch (e) { next(e); }
});
router.delete('/:id/valorisations/:valId', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const r = db.prepare(`
DELETE FROM investissements_pe_valorisations WHERE id = ? AND investissement_pe_id = ?
`).run(Number(req.params.valId), id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+110 -3
View File
@@ -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,
+1 -1
View File
@@ -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',
]);
+64 -7
View File
@@ -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); }
+39 -19
View File
@@ -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);
+26 -2
View File
@@ -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);
});
+17
View File
@@ -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,
+32 -1
View File
@@ -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;
+4
View File
@@ -15,9 +15,11 @@ import plateformesRouter from './routes/plateformes.js';
import categoriesRouter from './routes/categories.js';
import depotsRetraitsRouter from './routes/depotsRetraits.js';
import investissementsRouter from './routes/investissements.js';
import investissementsPeRouter from './routes/investissementsPe.js';
import remboursementsRouter from './routes/remboursements.js';
import simulRouter from './routes/simul.js';
import dashboardRouter from './routes/dashboard.js';
import dashboardPeRouter from './routes/dashboardPe.js';
import taxreportRouter from './routes/taxreport.js';
import platefomeTaxRouter from './routes/plateforme-tax.js';
import importsRouter from './routes/imports.js';
@@ -135,9 +137,11 @@ app.use('/api/plateformes', requireAuth, plateformesRouter);
app.use('/api/categories', requireAuth, categoriesRouter);
app.use('/api/depots-retraits', requireAuth, depotsRetraitsRouter);
app.use('/api/investissements', requireAuth, investissementsRouter);
app.use('/api/investissements-pe', requireAuth, investissementsPeRouter);
app.use('/api/remboursements', requireAuth, remboursementsRouter);
app.use('/api/simul', requireAuth, simulRouter);
app.use('/api/dashboard', requireAuth, dashboardRouter);
app.use('/api/dashboard-pe', requireAuth, dashboardPeRouter);
app.use('/api/taxreport', requireAuth, taxreportRouter);
app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter);
app.use('/api/imports', requireAuth, importsRouter);