Compare commits

..
45 Commits
Author SHA1 Message Date
ocroguennec eea69d77a4 Amélioration de la gestion du patrimoine 2026-09-30 16:26:14 +02:00
ocroguennec efa5de79b8 Notion de Regroupements Patrimoine 2026-09-28 14:30:01 +02:00
ocroguennec 9591ae400e Lien comptes courants / enveloppes 2026-09-28 10:02:22 +02:00
ocroguennec f507163369 maj 2026-09-28 08:38:05 +02:00
ocroguennec bce89d8112 Maj 2026-09-28 08:24:16 +02:00
ocroguennec 624d32ff7c maj 2026-09-27 15:09:18 +02:00
ocroguennec 1e3994f17f Amélioration 2026-09-27 11:54:18 +02:00
ocroguennec e0145c8727 Amélioration 2026-09-27 11:38:45 +02:00
ocroguennec add66a9310 fix 2026-09-26 18:48:18 +02:00
ocroguennec b18ffc63ee Maj Phase 2 - Institutions financières 2026-09-26 18:28:15 +02:00
ocroguennec 1dcb33aa74 Reférentiel des institutions financières 2026-09-26 17:59:08 +02:00
ocroguennec f2bdf29b16 Améliorations private equity 2026-09-26 16:37:40 +02:00
ocroguennec e8e3ca34cc Bug Prêts différés 2026-09-26 16:09:02 +02:00
ocroguennec 26197fc411 MAJ 2026-09-26 11:15:26 +02:00
ocroguennec 14cf4e5a19 Correction invitation 2026-09-19 19:28:59 +02:00
ocroguennec a58a8460f7 Fix lsur la restoration des sauvegardes 2026-09-19 19:09:52 +02:00
ocroguennec 49fab06012 Améliorations diverses 2026-09-19 19:00:12 +02:00
ocroguennec 8817ac693d MAj API pour PE 2026-09-19 17:34:09 +02:00
ocroguennec c918de1e7d Amélioration de la notion de workspace 2026-09-19 17:19:53 +02:00
ocroguennec 1d5737703b MAJ 2026-09-19 14:37:02 +02:00
ocroguennec 6bba34396c MAJ 2026-09-18 17:09:31 +02:00
ocroguennec 27c4c57a58 MAJ 2026-09-18 10:35:43 +02:00
ocroguennec 4cd4cbce71 Fix 2026-09-18 08:55:38 +02:00
ocroguennec d5792fb3e2 Maj 2026-09-18 08:45:15 +02:00
ocroguennec 337d0c1a2c Fix TVPI 2026-09-18 08:16:01 +02:00
ocroguennec c2dbe506c5 Bug 2026-09-18 08:01:50 +02:00
ocroguennec 0df335803e MAJ 2026-09-18 07:48:55 +02:00
ocroguennec 56d52d538e MAJ 2026-09-17 19:01:43 +02:00
ocroguennec 4b7df66862 Implementation de la notion d'espace d'investissement 2026-09-17 18:25:42 +02:00
ocroguennec 6b6cad7cd6 Mise en place des workspaces 2026-09-08 12:33:04 +02:00
ocroguennec be3c93897b Gestion des Thèmes 2026-09-06 18:59:09 +02:00
ocroguennec bab7a79b63 Améliorations 2026-09-05 17:05:54 +02:00
ocroguennec 8d104f2058 Correctif Bug activation IA 2026-09-05 15:23:14 +02:00
ocroguennec b3f5f80d2d Bug 2026-09-05 14:47:22 +02:00
ocroguennec f4d82e9687 bug 2026-09-05 11:11:30 +02:00
ocroguennec ef6bc71b36 Cororection bug 2026-09-05 11:07:23 +02:00
ocroguennec 662003fdd3 IA Assistant version initiale 2026-09-05 10:54:50 +02:00
ocroguennec 3e8508d48b Amélioration des frais 2026-09-04 08:38:32 +02:00
ocroguennec 981ddf5830 Correctif 2026-09-02 08:28:15 +02:00
ocroguennec 0334a63139 Mise en place de la feature de gestion des Frais 2026-09-01 22:16:18 +02:00
ocroguennec 9ea078302d Modification modale plateforme 2026-08-30 18:24:55 +02:00
ocroguennec 0c99887846 MAJ lié au 2042-C PRO 2026-08-30 17:03:31 +02:00
ocroguennec 33fee587aa Catégorisation des platformes et application de la fiscalité adapté (Impact PSAN , régime micro-BIC) 2026-08-30 16:53:04 +02:00
ocroguennec 8953d0bb35 Implementation de la feature de gestion des fichiers sur les investissements 2026-08-29 18:49:01 +02:00
ocroguennec f6829dec9d Amélioration de la logique des investissements vs dépôts 2026-08-29 16:03:55 +02:00
247 changed files with 33027 additions and 1184 deletions

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# .dockerignore à la racine du repo — nécessaire depuis que le contexte de
# build du backend est passé de ./backend à . (voir docker-compose.yml),
# pour que mcpBridge.js puisse réutiliser mcp-server/tools.js. Sans ce
# fichier, "docker compose build" enverrait tout le monorepo (frontend,
# node_modules de chaque service, .git...) au démon Docker à chaque build.
# Ne PAS exclure mcp-server/tools.js : c'est le seul fichier de mcp-server/
# dont le backend a besoin (voir backend/Dockerfile).
#
# Les motifs sont ancrés à la racine (préfixe /) pour ne PAS exclure les
# dossiers de même nom à l'intérieur de backend/ (ex. backend/scripts/,
# qui contient des scripts utiles, à ne pas confondre avec /scripts).
/.git
/.cowork
/backups
/design
/frontend
/scripts
node_modules
backend/data
backend/uploads
mcp-server/node_modules
.env
.env.*
*.log
npm-debug.log
+9
View File
@@ -48,3 +48,12 @@ UPLOAD_DIR=/app/uploads
# Identité de la plateforme (utilisée dans les emails et sur la page de connexion)
# APP_NAME=Crowdlending Tracker
# APP_URL=https://votre-domaine.com
# --- Assistant IA embarqué (optionnel) ---
# Clé de chiffrement des clés API des fournisseurs IA (Anthropic, OpenAI,
# OpenRouter...) enregistrées par l'admin — AES-256-GCM, réversible (les clés
# doivent être renvoyées en clair au fournisseur à chaque appel).
# Générer avec : openssl rand -hex 32
# Obligatoire dès qu'un fournisseur IA est configuré ; faire tourner cette
# clé invalide tous les fournisseurs déjà enregistrés (à re-saisir).
# AI_ENC_KEY=
+18
View File
@@ -0,0 +1,18 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = """ { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
];"""
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
new_item = """ { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
{ nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] },
];"""
content = content.replace(anchor, new_item, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Crowdlending enveloppe inserted OK, new length:", len(content))
@@ -0,0 +1,327 @@
import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
// requireAuth appliqué dans server.js
/**
* routes/dashboardPatrimoine.js — vue consolidée du patrimoine par regroupement (workspace
* "Patrimoine", chantier du 28/09/26, demande Olivier : "j'aimerais que l'on réfléchisse sur le
* comment la restitution du patrimoine va se passer... un espace de travail particulier :
* 'Patrimoine'"). Lecture seule, scope utilisateur (req.user.id) — pas de CRUD ici, seulement
* de l'agrégation en lecture sur des données déjà gérées ailleurs (Paramètres > Mes comptes,
* Emprunts, Admin > Enveloppes & regroupements).
*
* Cadrage validé par Olivier (AskUserQuestion, 28/09/26 (suite)) :
* - Respecter l'accès utilisateur : Crowdlending/Private Equity ne sont montrés/agrégés que
* si l'utilisateur a effectivement accès à ces workspaces (granted_by_admin=1 — pas
* seulement actif_by_user, cf. userHasWorkspaceAccess ci-dessous).
* - Un seul dashboard consolidé pour démarrer (pas de sous-pages par regroupement en v1).
*
* Périmètre :
* - Comptes courants / Comptes d'épargne / Comptes d'investissement / Immobilier / Crypto /
* Autres actifs : somme de comptes.solde_eur (saisie manuelle, cf. migration solde_eur dans
* db/index.js) rattachés via comptes.enveloppe_id → enveloppes_referentiel
* .regroupement_patrimonial_id (cf. plan_regroupements_patrimoniaux.md).
* - Passif : somme de emprunts.capital_restant_du.
* - Crowdlending et Private Equity : PAS des comptes.solde_eur (ces 2 regroupements n'ont
* aucune enveloppe eligible_compte=1, cf. `structurellement_vide` ci-dessous) — le total
* vient directement du portefeuille réel de leur workspace dédié, avec EXACTEMENT la même
* métrique que celle déjà affichée là-bas (crowdlendingCapitalInvesti /
* privateEquityCapitalInvesti ci-dessous — pas une nouvelle définition, un ré-affichage à
* l'identique). Ajouté le 29/09/26 suite à la remarque d'Olivier, qui s'attendait à voir
* cette valeur dès la v1 initiale (celle-ci se contentait d'un placeholder
* `portefeuille_a_venir`). N'apparaît que si l'utilisateur a effectivement accès au
* workspace correspondant (`access.crowdlending` / `access.private_equity`) — sinon le nœud
* reste `portefeuille_a_venir: true` avec un total à 0, et le frontend le masque entièrement.
*
* `structurellement_vide` (aucune enveloppe eligible_compte=1 dans ce regroupement, cf.
* migration eligible_compte, db/index.js — le sélecteur "Enveloppe" de Mes comptes ne propose
* QUE les enveloppes eligible_compte=1) signale un regroupement qui ne peut recevoir AUCUN
* solde via comptes.solde_eur, quoi que l'utilisateur saisisse : aujourd'hui Immobilier et
* Autres actifs (0 enveloppe éligible chacun — vérifié empiriquement ci-dessous, pas codé en
* dur). Crowdlending et Private Equity ont eux aussi 0 enveloppe éligible mais ne portent PAS
* ce flag : leur total vient du portefeuille réel (ci-dessus), pas d'une saisie manuelle en
* attente. Distinct d'un total à 0 € réellement saisi : le frontend doit l'afficher
* explicitement ("pas encore de saisie possible ici") plutôt qu'un "0 €" qui laisserait croire
* que le patrimoine de cette famille est nul.
*
* `total_eur` d'un regroupement PARENT (aujourd'hui : "Comptes d'investissement", seul
* regroupement à avoir un enfant, "Private Equity") est un total ROULÉ, cf. buildNode :
* total propre du parent (ses comptes.solde_eur rattachés directement) + total de chacun de
* ses enfants. Corrigé le 29/09/26 (retour d'Olivier : "la somme des sous-catégories doit se
* retrouver remontée en total au niveau de 'comptes d'investissement'" — bug de la v1, qui
* affichait le total du parent sans ses enfants).
*
* `lignes` (29/09/26, retour d'Olivier : "je voudrais... un système... pour développer soit par
* plateforme ou par compte/livret/produit") — détail par regroupement pour l'affichage
* dépliable côté frontend : liste des comptes (nom, établissement, détenteur, solde) pour les
* regroupements alimentés par comptes.solde_eur, liste des emprunts pour Passif, et répartition
* par plateforme (capital investi, nb d'investissements, détenteur agrégé) pour Crowdlending/Private Equity —
* "je m'attendrais à voir au moins le capital investi par plateforme". Pas de colonne
* variation/performance (contrairement aux captures d'écran d'app tierce qu'Olivier a
* partagées en référence) : comptes.solde_eur et emprunts.capital_restant_du sont des valeurs
* instantanées, sans historique conservé en base — un vrai suivi de performance nécessiterait
* de capturer un solde à chaque date (hors périmètre de ce chantier).
*/
function userHasWorkspaceAccess(userId, type) {
const row = db.prepare(`
SELECT 1 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.type = ?
LIMIT 1
`).get(userId, type);
return !!row;
}
// Regroupements dont le total ne vient pas de comptes.solde_eur mais du portefeuille réel du
// workspace dédié — cf. commentaire d'en-tête. Identifiés par nom (pas d'autre marqueur en base
// à ce jour) plutôt que par une colonne dédiée : seul cas d'usage pour l'instant, ne justifie
// pas une migration.
const PORTEFEUILLE_NOMS = new Set(['Crowdlending', 'Private Equity']);
// "Capital investi" Crowdlending (29/09/26, suite à la remarque d'Olivier qui s'attendait à
// voir cette valeur dès la v1) — reprend EXACTEMENT la même métrique que le KPI "Capital
// investi" du Dashboard crowdlending (Dashboard.jsx : portfolio.encours + portfolio.en_defaut,
// cf. routes/dashboard.js) : capital actuellement engagé (statuts en_cours/en_retard/procedure
// uniquement — exclut rembourse/cloture), net des réinvestissements et remboursements de
// capital. Pas une nouvelle définition : un ré-affichage à l'identique, agrégé sur tous les
// investisseurs de l'utilisateur (comme comptes/emprunts ailleurs dans ce fichier).
const CL_CAPITAL_CASE = `CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
ELSE 0 END`;
function crowdlendingCapitalInvesti(userId) {
const row = db.prepare(`
SELECT COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS capital_investi
FROM investissements i
WHERE i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
`).get(userId);
return row.capital_investi;
}
// Détenteur agrégé pour une ligne groupée par plateforme (Crowdlending/PE) : GROUP_CONCAT
// DISTINCT ramène tous les détenteurs distincts séparés par virgule — un seul nom si tous les
// investissements de la plateforme partagent le même détenteur, "Plusieurs" s'ils divergent
// (foyer avec plusieurs investisseurs sur la même plateforme).
//
// Corrigé le 29/09/26 (suite, suite) : `inv.nom` seul, plus de concaténation avec `inv.prenom`.
// Olivier a signalé un second problème sur sa base réelle une fois la colonne Détenteur
// remplie ("Encore un pb sur l'affichage des détenteurs") : "Olivier Olivier CROGUENNEC" au
// lieu de "Olivier CROGUENNEC". Cause : investisseurs.nom stocke déjà le NOM COMPLET
// (prénom + nom de famille) pour un profil "famille" — cf. INSERT dans auth.js (création du
// profil principal à l'inscription : `nom = fullName`, `prenom` extrait séparément du même
// fullName) et FamilleEntreprises.jsx (le formulaire d'édition reconstruit `nom_famille` en
// retirant le prénom de `nom` : `m.nom.replace(m.prenom, '')`, preuve que `nom` contient déjà
// tout). Pour un profil "entreprise", `prenom` est NULL et `nom` est simplement la raison
// sociale. Dans les deux cas, `inv.nom` seul EST le nom d'affichage — le préfixer par
// `inv.prenom` (comme le faisaient encore `comptesLignesStmt`/`empruntsLignesStmt` plus bas,
// corrigés au même moment) dupliquait le prénom. Vérifié en lecture seule sur une copie locale
// de la base réelle d'Olivier (investisseurs.nom = "Olivier CROGUENNEC", "Capucine CROGUENNEC",
// "Marine CROGUENNEC" — jamais juste le nom de famille).
function toDetenteur(concat) {
if (!concat) return null;
const noms = [...new Set(concat.split(',').map(s => s.trim()).filter(Boolean))];
if (noms.length === 0) return null;
if (noms.length === 1) return noms[0];
return 'Plusieurs';
}
// Répartition du capital investi Crowdlending par plateforme (même métrique que
// crowdlendingCapitalInvesti ci-dessus, groupée) — demande Olivier 29/09/26 : "je m'attendrais
// à voir au moins le capital investi par plateforme". `detenteur` ajouté le 29/09/26 (suite) :
// Olivier a signalé que la colonne Détenteur restait vide sur ces lignes ("Cela n'a pas l'air
// de fonctionner pour les détenteurs") — chaque investissement porte bien un investisseur_id
// (détenteur), l'agrégation par plateforme se contentait jusque-là de ne pas le remonter.
function crowdlendingLignes(userId) {
const rows = db.prepare(`
SELECT p.id AS id, p.nom AS label,
COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS valeur_eur,
COUNT(CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN 1 END) AS nb_lignes,
GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
GROUP BY p.id, p.nom
HAVING valeur_eur > 0
ORDER BY valeur_eur DESC
`).all(userId);
return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) }));
}
// "Capital investi total" Private Equity — reprend EXACTEMENT la même métrique que le KPI
// "Capital investi total" du Dashboard PE (DashboardPe.jsx : somme de montant_investi sur tous
// les deals, tous statuts confondus — modèle PE différent du crowdlending, pas d'échéancier ni
// de notion d'encours net). Agrégé sur tous les investisseurs de l'utilisateur, tous workspaces
// de type private_equity confondus (le type peut avoir plusieurs exemplaires, cf.
// adminWorkspaces.js).
function privateEquityCapitalInvesti(userId) {
const row = db.prepare(`
SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi
FROM investissements_pe
WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
`).get(userId);
return row.capital_investi;
}
// `detenteur` ajouté le 29/09/26 — même correction que crowdlendingLignes ci-dessus.
function privateEquityLignes(userId) {
const rows = db.prepare(`
SELECT p.id AS id, p.nom AS label,
COALESCE(SUM(ipe.montant_investi), 0) AS valeur_eur,
COUNT(*) AS nb_lignes,
GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
GROUP BY p.id, p.nom
HAVING valeur_eur > 0
ORDER BY valeur_eur DESC
`).all(userId);
return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) }));
}
router.get('/', (req, res) => {
const userId = req.user.id;
const roots = db.prepare(`
SELECT id, nom, parent_id, type, ordre_affichage
FROM regroupements_patrimoniaux
WHERE parent_id IS NULL
ORDER BY ordre_affichage, nom
`).all();
const children = db.prepare(`
SELECT id, nom, parent_id, type, ordre_affichage
FROM regroupements_patrimoniaux
WHERE parent_id IS NOT NULL
ORDER BY ordre_affichage, nom
`).all();
const soldeStmt = db.prepare(`
SELECT
COALESCE(SUM(c.solde_eur), 0) AS total,
SUM(CASE WHEN c.solde_eur IS NOT NULL THEN 1 ELSE 0 END) AS nb_avec_solde,
SUM(CASE WHEN c.solde_eur IS NULL THEN 1 ELSE 0 END) AS nb_sans_solde
FROM comptes c
JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id
WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ?
`);
// Lignes de détail "par compte" — établissement (institution du référentiel, sinon le champ
// texte libre banque) et détenteur, pour l'affichage dépliable (cf. commentaire d'en-tête).
// Ne remonte que les comptes avec un solde saisi (NULL exclu, non pertinent en liste).
const comptesLignesStmt = db.prepare(`
SELECT c.id AS id, c.nom AS label,
COALESCE(ir.nom, c.banque) AS sous_label,
c.solde_eur AS valeur_eur,
inv.nom AS detenteur
FROM comptes c
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id
WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ? AND c.solde_eur IS NOT NULL
ORDER BY c.solde_eur DESC
`);
const eligibleCountStmt = db.prepare(`
SELECT COUNT(*) AS n FROM enveloppes_referentiel
WHERE regroupement_patrimonial_id = ? AND eligible_compte = 1
`);
const passifStmt = db.prepare(`
SELECT COALESCE(SUM(capital_restant_du), 0) AS total, COUNT(*) AS n
FROM emprunts WHERE user_id = ? AND regroupement_patrimonial_id = ?
`);
const empruntsLignesStmt = db.prepare(`
SELECT e.id AS id, e.nom AS label,
ir.nom AS sous_label,
e.capital_restant_du AS valeur_eur,
inv.nom AS detenteur
FROM emprunts e
LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id
LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id
WHERE e.user_id = ? AND e.regroupement_patrimonial_id = ?
ORDER BY e.capital_restant_du DESC
`);
const accessCache = {
crowdlending: userHasWorkspaceAccess(userId, 'crowdlending'),
private_equity: userHasWorkspaceAccess(userId, 'private_equity'),
};
function buildNode(r) {
const isPortefeuille = PORTEFEUILLE_NOMS.has(r.nom);
const eligibleCount = eligibleCountStmt.get(r.id).n;
const structurellementVide = !isPortefeuille && eligibleCount === 0;
let ownTotalEur = 0, nbAvecSolde = 0, nbSansSolde = 0, sourcePortefeuille = false, portefeuilleAVenir = false;
let lignes = [];
if (r.nom === 'Crowdlending') {
if (accessCache.crowdlending) {
ownTotalEur = crowdlendingCapitalInvesti(userId);
sourcePortefeuille = true;
lignes = crowdlendingLignes(userId);
} else {
portefeuilleAVenir = true;
}
} else if (r.nom === 'Private Equity') {
if (accessCache.private_equity) {
ownTotalEur = privateEquityCapitalInvesti(userId);
sourcePortefeuille = true;
lignes = privateEquityLignes(userId);
} else {
portefeuilleAVenir = true;
}
} else if (r.type === 'actif') {
const s = soldeStmt.get(userId, r.id);
ownTotalEur = s.total; nbAvecSolde = s.nb_avec_solde; nbSansSolde = s.nb_sans_solde;
lignes = comptesLignesStmt.all(userId, r.id);
} else {
const p = passifStmt.get(userId, r.id);
ownTotalEur = p.total; nbAvecSolde = p.n; nbSansSolde = 0;
lignes = empruntsLignesStmt.all(userId, r.id);
}
const enfants = children.filter(ch => ch.parent_id === r.id).map(buildNode);
// Total ROULÉ (29/09/26, correctif) : propre total + total de chaque enfant — un enfant
// (ex. Private Equity) n'a lui-même pas d'enfant (hiérarchie à 2 niveaux max, cf.
// regroupementsPatrimoniaux.js), donc pas de risque de double-comptage plus profond.
const totalEur = ownTotalEur + enfants.reduce((s, e) => s + e.total_eur, 0);
return {
id: r.id,
nom: r.nom,
type: r.type,
ordre_affichage: r.ordre_affichage,
total_eur: totalEur,
nb_comptes_avec_solde: nbAvecSolde,
nb_comptes_sans_solde: nbSansSolde,
structurellement_vide: structurellementVide,
portefeuille_a_venir: portefeuilleAVenir,
source_portefeuille: sourcePortefeuille,
lignes,
enfants,
};
}
const tree = roots.map(buildNode);
// Les totaux racine sont désormais roulés (ils incluent déjà leurs enfants, cf. buildNode) —
// une simple somme des racines par type suffit, plus besoin de descendre récursivement dans
// l'arbre (la hiérarchie ne dépasse de toute façon pas 2 niveaux, cf. plus haut).
const totalActif = tree.filter(n => n.type === 'actif').reduce((s, n) => s + n.total_eur, 0);
const totalPassif = tree.filter(n => n.type === 'passif').reduce((s, n) => s + n.total_eur, 0);
res.json({
access: accessCache,
total_actif_eur: totalActif,
total_passif_eur: totalPassif,
net_eur: totalActif - totalPassif,
regroupements: tree,
});
});
export default router;
@@ -0,0 +1,36 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# 1) Nouvelle catégorie "Investissement forestier & viticole" (GFF + GFV) ajoutée à la liste des
# catégories toujours insérées (INSERT OR IGNORE).
anchor_cat = " for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection']) {"
assert content.count(anchor_cat) == 1, f"anchor_cat count={content.count(anchor_cat)}"
new_cat = " for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection', 'Investissement forestier & viticole']) {"
content = content.replace(anchor_cat, new_cat, 1)
# 2) 7 nouvelles enveloppes ajoutées à EXTRA_ENVELOPPES (demande Olivier du 27/09/26, à partir
# d'une capture d'écran d'un site de conseil en gestion de patrimoine listant des placements
# absents du référentiel) : Dette privée, capital-investissement réservé (FPCI/SLP/FPS),
# Infrastructures non cotées, Groupement Foncier Forestier, Groupement Foncier Viticole,
# LMNP/LMP (distinct de l'immobilier locatif nu déjà présent), SCPI en démembrement.
anchor_last_item = """ { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] },
];"""
assert content.count(anchor_last_item) == 1, f"anchor_last_item count={content.count(anchor_last_item)}"
new_items = """ { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] },
{ nom: 'Dette privée (fonds)', categories: ['Financement participatif / private equity'], description: "Fonds de dette privée (private debt) — prêts directs à des entreprises via un véhicule de fonds professionnel, hors plateforme de crowdlending grand public.", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] },
{ nom: 'Capital-investissement réservé (FPCI / SLP / FPS)', categories: ['Financement participatif / private equity'], description: "Fonds de capital-investissement réservés aux investisseurs avertis/professionnels — Fonds Professionnel de Capital Investissement (FPCI), Société de Libre Partenariat (SLP), Fonds Professionnel Spécialisé (FPS) ; distinct du FCPR grand public (support disponible en assurance-vie/PER/contrat de capitalisation) et de la participation directe non cotée (prise de participation en direct, hors fonds).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les plus-values de cession de parts (flat tax / PFU, ou option barème) ; exonération possible sous conditions de durée de détention, comme pour un FCPR", supports: [] },
{ nom: 'Infrastructures non cotées', categories: ['Financement participatif / private equity'], description: "Fonds d'investissement dans des actifs d'infrastructure non cotés (énergie, transport, télécoms...).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] },
{ nom: 'Groupement Foncier Forestier (GFF)', categories: ['Investissement forestier & viticole'], description: "Investissement forestier via un groupement foncier forestier — parts donnant une quote-part de bois et forêts en gestion collective.", plafond_eur: null, regime_fiscal: "Réduction d'IR à l'entrée possible sous conditions (dispositif DEFI-Forêt), exonération partielle d'IFI, transmission facilitée sous conditions d'engagement de gestion durable", supports: [] },
{ nom: 'Groupement Foncier Viticole (GFV)', categories: ['Investissement forestier & viticole'], description: "Investissement viticole via un groupement foncier viticole — parts donnant une quote-part de vignes louées à un exploitant via un bail rural.", plafond_eur: null, regime_fiscal: "Revenus fonciers (loyers du bail rural), exonération partielle d'IFI et de droits de succession/donation sous conditions", supports: [] },
{ nom: 'Immobilier locatif meublé (LMNP/LMP)', categories: ['Immobilier physique'], description: "Location meublée en direct — statut Loueur en Meublé Non Professionnel (LMNP) ou Professionnel (LMP) selon les seuils de recettes et la composition du foyer fiscal ; distinct de « Immobilier locatif (détention directe) » qui suppose une location nue (revenus fonciers).", plafond_eur: null, regime_fiscal: "Revenus imposés en BIC (micro-BIC ou régime réel avec amortissement du bien) ; le statut LMP ouvre en plus à l'imputation des déficits sur le revenu global et à un régime de plus-value professionnelle", supports: [] },
{ nom: 'SCPI en démembrement (nue-propriété / usufruit temporaire)', categories: ['Immobilier papier'], description: "SCPI achetée en démembrement de propriété (nue-propriété ou usufruit temporaire), typiquement sur 5 à 10 ans.", plafond_eur: null, regime_fiscal: "Usufruitier : perçoit les loyers et est imposé en revenus fonciers pendant la durée du démembrement. Nu-propriétaire : aucun revenu ni imposition pendant la période, récupère la pleine propriété sans fiscalité à l'extinction ; non redevable de l'IFI sur la valeur du bien démembré pendant la durée (sauf exceptions)", supports: [] },
];"""
content = content.replace(anchor_last_item, new_items, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Dette privée / FPCI-SLP-FPS / Infrastructures / GFF / GFV / LMNP-LMP / SCPI démembrement inserted OK, new length:", len(content))
+47
View File
@@ -0,0 +1,47 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = "\nexport default db;\n"
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = '''
// Extension du 29/09/26 (suite) — support "ETC" (demande Olivier, à partir d'une capture d'écran
// d'un contrat d'assurance-vie distinguant "+200 ETF & 22 ETC" parmi ses supports) : un Exchange
// Traded Commodity (ETC) est un instrument coté qui réplique le cours d'une matière première
// (or, pétrole...), proche d'un ETF dans sa mécanique de cotation mais sans statut UCITS/OPCVM —
// distinct du support "ETF / trackers" (fonds indiciels UCITS) et de "Matières premières / métaux
// précieux" (autre modalité d'exposition aux matières premières, ex. en unités de compte
// d'assurance-vie). Rattaché aux mêmes enveloppes que "Fonds monétaires" ci-dessus (menu de
// supports déjà large) ; PEA/PEA-PME exclus, comme en pratique un ETC matières premières
// physiques n'est pas éligible au PEA. Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom).
{
db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run(
'ETC (Exchange Traded Commodities)',
"Tracker coté sur une matière première (or, pétrole, panier de matières premières...) — proche de l'ETF dans sa mécanique de cotation, mais sans statut UCITS/OPCVM ; distinct du support « ETF / trackers » et de « Matières premières / métaux précieux »."
);
const getSuppRefIdEtc = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?');
const suppEtc = getSuppRefIdEtc.get('ETC (Exchange Traded Commodities)');
if (suppEtc) {
const getEnvRefIdEtc = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const linkEnvSuppEtc = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)');
for (const envNom of [
'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel',
'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation',
'Assurance-vie luxembourgeoise',
]) {
const env = getEnvRefIdEtc.get(envNom);
if (env) linkEnvSuppEtc.run(env.id, suppEtc.id);
}
}
}
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Support ETC inserted OK, new length:", len(content))
+84
View File
@@ -0,0 +1,84 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# 1) Insérer la liste des 11 supports (dupliquée localement, hors de portée du AV_PER_SUPPORTS
# du bloc de seed initial) juste avant EXTRA_ENVELOPPES.
anchor_supports = " const EXTRA_ENVELOPPES = [\n"
assert content.count(anchor_supports) == 1, f"anchor_supports count={content.count(anchor_supports)}"
supports_const = """ const AV_PER_SUPPORTS_EXTRA = [
'Fonds en euros', 'OPCVM / fonds actifs', 'ETF / trackers', 'Titres vifs (actions)',
'Obligations / fonds datés', 'Immobilier papier (SCPI/SCI/OPCI)', 'Private equity (FCPR)',
'Produits structurés', 'Matières premières / métaux précieux', 'Gestion pilotée / mandat',
'Fonds solidaires (90/10)',
];
"""
content = content.replace(anchor_supports, supports_const + anchor_supports, 1)
# 2) Ajouter les nouvelles enveloppes à la fin du tableau EXTRA_ENVELOPPES : Contrat de
# capitalisation, Assurance-vie luxembourgeoise, Girardin (demande du 29/09/26 sur les
# enveloppes manquantes / actifs "exotiques"), et Wallet crypto conservé par un tiers
# (demande du 29/09/26, distincte de l'auto-conservation et du compte sur exchange).
anchor_last_item = """ { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] },
];"""
assert content.count(anchor_last_item) == 1, f"anchor_last_item count={content.count(anchor_last_item)}"
new_items = """ { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] },
{ nom: 'Contrat de capitalisation', categories: ['Assurance-vie'], description: "Support d'épargne proche de l'assurance-vie (fonds euros + unités de compte), mais sans clause bénéficiaire ni dénouement au décès — entre dans la succession classique. Peut être souscrit par une personne physique ou une personne morale (société, SCI...).", plafond_eur: null, regime_fiscal: "Fiscalité des gains identique à l'assurance-vie pour un particulier (abattement après 8 ans, PFU ou PFL) ; pas de régime de succession spécifique (articles 990 I / 757 B) ; transmissible par donation du vivant, contrairement à l'assurance-vie", supports: AV_PER_SUPPORTS_EXTRA },
{ nom: 'Assurance-vie luxembourgeoise', categories: ['Assurance-vie'], description: "Contrat d'assurance-vie souscrit auprès d'un assureur luxembourgeois — triangle de sécurité (actifs ségrégués auprès d'une banque dépositaire distincte de l'assureur), univers d'investissement et devises généralement plus larges qu'un contrat français.", plafond_eur: null, regime_fiscal: "Fiscalité identique à l'assurance-vie française pour un résident fiscal français (abattement après 8 ans, PFU/PFL) ; déclaration du contrat étranger requise (formulaire 3916-3916 bis)", supports: AV_PER_SUPPORTS_EXTRA },
{ nom: 'Girardin (industriel / social)', categories: ['Financement participatif / private equity'], description: "Investissement dans un dispositif de défiscalisation outre-mer — financement d'un investissement productif ou de logement social en DOM-TOM, en échange d'une réduction d'impôt immédiate. Capital non récupéré (pas de valeur résiduelle), contrairement à un FCPI/FIP.", plafond_eur: null, regime_fiscal: "Réduction d'IR l'année suivant l'investissement, généralement supérieure au montant investi (effet de levier) ; plafonnement spécifique des niches fiscales outre-mer ; risque de remise en cause fiscale si le monteur/exploitant ne respecte pas ses engagements (durée de conservation 5 ans)", supports: [] },
{ nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
];"""
content = content.replace(anchor_last_item, new_items, 1)
# 3) Étendre la boucle de traitement pour gérer aussi les supports (jusqu'ici uniquement les
# catégories, car aucune des 7 enveloppes du 29/09/26 n'avait de support).
anchor_loop = """ const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)');
const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?');
const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)');
db.transaction(() => {
for (const { nom, description, plafond_eur, regime_fiscal, categories } of EXTRA_ENVELOPPES) {
insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null);
const env = getEnvRefIdExtra.get(nom);
if (!env) continue;
for (const catNom of categories) {
const cat = getCatEnvIdExtra.get(catNom);
if (cat) linkEnvCatExtra.run(env.id, cat.id);
}
}
})();
}"""
assert content.count(anchor_loop) == 1, f"anchor_loop count={content.count(anchor_loop)}"
new_loop = """ const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)');
const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?');
const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)');
const getSuppRefIdExtra = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?');
const linkEnvSuppExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)');
db.transaction(() => {
for (const { nom, description, plafond_eur, regime_fiscal, categories, supports } of EXTRA_ENVELOPPES) {
insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null);
const env = getEnvRefIdExtra.get(nom);
if (!env) continue;
for (const catNom of categories) {
const cat = getCatEnvIdExtra.get(catNom);
if (cat) linkEnvCatExtra.run(env.id, cat.id);
}
for (const suppNom of (supports || [])) {
const supp = getSuppRefIdExtra.get(suppNom);
if (supp) linkEnvSuppExtra.run(env.id, supp.id);
}
}
})();
}"""
content = content.replace(anchor_loop, new_loop, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Extension 2 (contrat de capitalisation, AV luxembourgeoise, Girardin, wallet tiers) inserted OK, new length:", len(content))
+71
View File
@@ -0,0 +1,71 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = """db.prepare(`
UPDATE enveloppes_referentiel SET plafond_eur = 61200, updated_at = datetime('now')
WHERE nom = 'PEL' AND plafond_eur IS NULL
`).run();
export default db;
"""
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = '''db.prepare(`
UPDATE enveloppes_referentiel SET plafond_eur = 61200, updated_at = datetime('now')
WHERE nom = 'PEL' AND plafond_eur IS NULL
`).run();
// Extension du 29/09/26 (demande Olivier) : "je ne voudrais pas faire un nième référentiel [...]
// il faut peut-être que cela soit des catégories supplémentaires même si ce n'a pas tout à fait
// le même statut" — plutôt qu'un 3e référentiel séparé pour les actifs "physiques"/hors
// enveloppe fiscale (immobilier direct, crypto, métaux précieux, objets de collection,
// participations non cotées), ils sont intégrés au référentiel existant : quelques nouvelles
// catégories (familles) dans categories_enveloppes, et des enveloppes rattachées, sur le même
// principe que les enveloppes SCPI/OPCI/SCI détenues en direct déjà présentes (aucun support —
// détention directe, pas de menu de supports ; aucun institution_id — catalogue seul, comme le
// reste de ce référentiel). Les participations non cotées réutilisent la catégorie existante
// "Financement participatif / private equity" plutôt que d'en créer une nouvelle (même famille
// que le Crowdequity et les FCPI/FIP). Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom)
// — s'ajoute sans écraser à un référentiel déjà seedé, comme le backfill CEL/PEL ci-dessus.
{
const insCatEnvExtra = db.prepare('INSERT OR IGNORE INTO categories_enveloppes (nom) VALUES (?)');
for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection']) {
insCatEnvExtra.run(nom);
}
const EXTRA_ENVELOPPES = [
{ nom: 'Résidence principale', categories: ['Immobilier physique'], description: "Logement occupé à titre de résidence principale.", plafond_eur: null, regime_fiscal: "Plus-value de cession exonérée (résidence principale)", supports: [] },
{ nom: 'Immobilier locatif (détention directe)', categories: ['Immobilier physique'], description: "Bien immobilier loué, détenu en direct (hors SCPI/SCI).", plafond_eur: null, regime_fiscal: "Revenus fonciers (micro-foncier ou réel) + plus-value immobilière (abattement selon durée de détention)", supports: [] },
{ nom: 'Compte crypto (exchange)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues sur une plateforme d'échange centralisée.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
{ nom: 'Wallet crypto (auto-conservation)', categories: ['Actifs numériques'], description: "Cryptomonnaies en auto-conservation (wallet matériel ou logiciel).", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
{ nom: 'Métaux précieux physiques', categories: ['Métaux précieux'], description: "Or, argent... détenus physiquement (lingots, pièces).", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les métaux précieux (11 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] },
{ nom: 'Objets de collection', categories: ['Objets de collection'], description: "Montres, art, antiquités...", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les objets d'art/collection/antiquité (6 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] },
{ nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] },
];
const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)');
const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?');
const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)');
db.transaction(() => {
for (const { nom, description, plafond_eur, regime_fiscal, categories } of EXTRA_ENVELOPPES) {
insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null);
const env = getEnvRefIdExtra.get(nom);
if (!env) continue;
for (const catNom of categories) {
const cat = getCatEnvIdExtra.get(catNom);
if (cat) linkEnvCatExtra.run(env.id, cat.id);
}
}
})();
}
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Extension actifs directs inserted OK, new length:", len(content))
+58
View File
@@ -0,0 +1,58 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = """ })();
}
export default db;
"""
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = ''' })();
}
// Extension du 29/09/26 (demande Olivier, à propos de produits Spiko/Regular Finance) : "sais-tu
// comment les classifier ?" — Spiko et Regular Finance ne sont pas des enveloppes mais des
// PLATEFORMES/INSTITUTIONS donnant accès à un fonds monétaire (OPCVM monétaire, y compris
// lorsqu'il est tokenisé sur blockchain comme chez Spiko) : elles ont vocation à rejoindre
// institutions_referentiel (catégorie à créer, ex. "Gestion de trésorerie"), pas
// enveloppes_referentiel — le rattachement viendra en Phase 3. Ce qui manquait réellement à CE
// référentiel, c'est le SUPPORT lui-même : "OPCVM / fonds actifs" existant désigne des fonds
// actions/obligations/diversifiés gérés activement, pas un fonds monétaire (quasi-cash, duration
// très courte, profil de risque très différent) — les deux ne doivent pas être confondus.
// Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom), même principe que les extensions
// précédentes.
{
db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run(
'Fonds monétaires (OPCVM monétaire)',
"Fonds monétaire classique ou tokenisé sur blockchain (ex. Spiko, Regular Finance) — quasi-cash, duration très courte, distinct d'un OPCVM actions/obligations/diversifié."
);
const getSuppRefIdMonetaire = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?');
const suppMonetaire = getSuppRefIdMonetaire.get('Fonds monétaires (OPCVM monétaire)');
if (suppMonetaire) {
const getEnvRefIdMonetaire = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const linkEnvSuppMonetaire = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)');
// Enveloppes où un fonds monétaire a sa place (menu de supports déjà large, cohérent avec un
// placement défensif) — PEA/PEA-PME exclus (non éligibles réglementairement), enveloppes
// "cash" (Livret A, comptes courants...) exclues (déjà sans menu de supports par nature).
for (const envNom of [
'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel',
'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation',
'Assurance-vie luxembourgeoise',
]) {
const env = getEnvRefIdMonetaire.get(envNom);
if (env) linkEnvSuppMonetaire.run(env.id, suppMonetaire.id);
}
}
}
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Fonds monétaires support + links inserted OK, new length:", len(content))
@@ -0,0 +1,53 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# 1) Retrait de "Gestion pilotée / mandat" du seed initial des supports (SEED_SUPPORTS) — ne
# concerne que les installations neuves, ce bloc étant gardé par COUNT()===0.
old_seed_line = " { nom: 'Gestion pilotée / mandat', description: 'Allocation déléguée à un gérant selon un profil de risque — mode par défaut de nombreux PER.' },\n"
assert content.count(old_seed_line) == 1, f"old_seed_line count={content.count(old_seed_line)}"
content = content.replace(old_seed_line, "", 1)
# 2) Retrait de 'Gestion pilotée / mandat' des deux listes de supports "classiques" AV/PER
# (AV_PER_SUPPORTS et AV_PER_SUPPORTS_EXTRA) — présent dans les deux car dupliqué (la seconde
# liste, hors de portée du bloc de seed initial, a été recopiée lors de l'extension du 29/09/26
# pour Contrat de capitalisation / Assurance-vie luxembourgeoise).
old_list_fragment = "'Produits structurés', 'Matières premières / métaux précieux', 'Gestion pilotée / mandat',"
assert content.count(old_list_fragment) == 2, f"old_list_fragment count={content.count(old_list_fragment)}"
new_list_fragment = "'Produits structurés', 'Matières premières / métaux précieux',"
content = content.replace(old_list_fragment, new_list_fragment) # replace_all (2 occurrences)
# 3) Nouveau bloc toujours exécuté : supprime la ligne "Gestion pilotée / mandat" pour les
# installations déjà seedées (le retrait du texte source ci-dessus ne corrige que les
# installations neuves). ON DELETE CASCADE sur enveloppes_supports_disponibles nettoie
# automatiquement les liens existants vers ce support — c'est un pur catalogue (aucune donnée
# d'investissement réelle n'y est aujourd'hui rattachée), donc la suppression est sans risque.
# Toujours exécuté et idempotent (DELETE sans WHERE de garde spécifique au-delà du nom — un
# deuxième passage ne trouve simplement plus rien à supprimer).
anchor = "\nexport default db;\n"
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = '''
// Correctif du 29/09/26 (suite) — retrait du support "Gestion pilotée / mandat" (demande
// Olivier, à propos d'une capture d'écran distinguant "3 modes de gestion" — libre / pilotée /
// mixte — d'un contrat d'assurance-vie) : le "mode de gestion" décrit QUI décide de l'allocation
// (le titulaire, ou un gérant délégué selon un mandat), c'est une dimension orthogonale au
// SUPPORT (le type d'instrument financier détenu — ETF, actions, SCPI...). Une gestion pilotée
// s'applique justement à un panier de vrais supports (fonds euros, fonds datés, SCPI, produits
// structurés...), elle n'en est pas un elle-même. Cette ligne datait du seed initial du 28/09/26
// et n'aurait pas dû être modélisée comme support ; elle est retirée du référentiel et de la
// liste de supports "classiques" AV/PER (voir plus haut). Toujours exécuté et idempotent —
// DELETE cascade automatiquement les liens vers cette entrée (enveloppes_supports_disponibles,
// ON DELETE CASCADE) ; aucune donnée d'investissement réelle n'est rattachée à ce catalogue à ce
// stade, la suppression est donc sans risque de perte de données utilisateur.
db.prepare("DELETE FROM supports_referentiel WHERE nom = 'Gestion pilotée / mandat'").run();
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Gestion pilotée / mandat removed from supports OK, new length:", len(content))
@@ -0,0 +1,102 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Liste des 12 lignes à corriger : (nom, ancien texte exact regime_fiscal, nouveau texte)
FIXES = [
('Compte courant rémunéré',
'Flat tax 30 %',
'Flat tax (PFU)'),
('Compte rémunéré nouvelle génération (fintech)',
'Flat tax 30 %',
'Flat tax (PFU)'),
('Livret bancaire non réglementé',
'Flat tax 30 % (ou option barème)',
'Flat tax (PFU) ou option barème'),
('Compte à terme (CAT)',
'Flat tax 30 % (ou option barème)',
'Flat tax (PFU) ou option barème'),
('Compte-titres ordinaire (CTO)',
"Flat tax 30 % (ou option barème), pas d'avantage fiscal",
"Flat tax (PFU) ou option barème, pas d'avantage fiscal"),
('Crowdfunding immobilier',
'Fiscalité classique sur les intérêts (flat tax 30 % ou barème)',
'Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)'),
('Compte crypto (exchange)',
"Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086",
"Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"),
('Wallet crypto (auto-conservation)',
"Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086",
"Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"),
('Métaux précieux physiques',
"Taxe forfaitaire sur les métaux précieux (11 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat",
"Taxe forfaitaire sur les métaux précieux, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat"),
('Objets de collection',
"Taxe forfaitaire sur les objets d'art/collection/antiquité (6 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat",
"Taxe forfaitaire sur les objets d'art/collection/antiquité, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat"),
('Participation directe non cotée (startups/PME)',
"Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)",
"Flat tax (PFU) sur les plus-values (ou option barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)"),
('Wallet crypto (conservation par un tiers)',
"Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086",
"Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"),
]
# 1) Réécriture du texte source, ligne par ligne (chaque ligne contient "nom: '<X>'," ce qui la
# rend unique même quand deux lignes partagent le même ancien texte regime_fiscal).
lines = content.split("\n")
touched = {nom: False for nom, _, _ in FIXES}
for i, line in enumerate(lines):
for nom, old_txt, new_txt in FIXES:
needle_nom = f"nom: '{nom}'"
if needle_nom in line and old_txt in line and not touched[nom]:
assert line.count(old_txt) == 1, f"{nom}: old_txt found {line.count(old_txt)} times on its line"
lines[i] = line.replace(old_txt, new_txt, 1)
touched[nom] = True
break
missing = [nom for nom, ok in touched.items() if not ok]
assert not missing, f"lignes non trouvées pour : {missing}"
content = "\n".join(lines)
# 2) Bloc de backfill toujours exécuté (INSERT OR IGNORE ne corrige pas le texte des lignes déjà
# présentes) : UPDATE ... WHERE nom = ? AND regime_fiscal = '<ancien texte exact>' — ne touche
# jamais une ligne dont regime_fiscal a été modifié manuellement (le texte ne matchera plus).
anchor = "\nexport default db;\n"
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
backfill_stmts = []
for nom, old_txt, new_txt in FIXES:
nom_js = nom.replace("\\", "\\\\").replace("'", "\\'")
old_js = old_txt.replace("\\", "\\\\").replace("'", "\\'")
new_js = new_txt.replace("\\", "\\\\").replace("'", "\\'")
backfill_stmts.append(
" db.prepare(\n"
" \"UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') \" +\n"
" \"WHERE nom = ? AND regime_fiscal = ?\"\n"
f" ).run('{new_js}', '{nom_js}', '{old_js}');"
)
block = (
"\n"
"// Correctif du 27/09/26 (demande Olivier : \"Tu alimente la description avec un flat tax à\n"
"// 30%, mais ce n'est plus vrai en 2026... évite de mettre le taux actuel qui peut varier\") —\n"
"// les taux d'imposition précis (flat tax 30 %, taxes forfaitaires 11 %/6 %...) changent dans\n"
"// le temps et rien ici ne les tient à jour : les textes ci-dessus (SEED_ENVELOPPES /\n"
"// EXTRA_ENVELOPPES) ont été réécrits pour décrire le MÉCANISME fiscal (flat tax/PFU, taxe\n"
"// forfaitaire...) sans figer de pourcentage. Comme INSERT OR IGNORE ne corrige pas le texte\n"
"// d'une ligne déjà présente, ce backfill patche les installations déjà seedées — toujours\n"
"// exécuté, guardé par l'ancien texte exact pour ne jamais écraser une modification manuelle\n"
"// faite depuis l'admin (même principe que le backfill des plafonds CEL/PEL).\n"
"{\n"
+ "\n".join(backfill_stmts) + "\n"
"}\n"
"\nexport default db;\n"
)
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Regime fiscal genericization + backfill inserted OK, new length:", len(content))
+13 -3
View File
@@ -3,17 +3,27 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
COPY backend/package.json backend/package-lock.json* ./
RUN npm install --omit=dev
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY backend/. .
# src/ai/mcpBridge.js importe "../../../mcp-server/tools.js" (réutilisation
# telle quelle du code d'outillage du serveur MCP externe) — on reproduit
# donc ici la même position relative : /mcp-server à côté de /app.
COPY mcp-server/tools.js /mcp-server/tools.js
# Node résout les imports nus de tools.js (zod, jsdom, @mozilla/readability)
# en remontant les node_modules à partir de SON PROPRE dossier (/mcp-server),
# pas de celui de mcpBridge.js qui l'importe — donc /app/node_modules seul ne
# suffit pas, même si ces paquets y sont installés (voir package.json). On
# lui donne accès aux mêmes dépendances via un lien symbolique.
RUN ln -s /app/node_modules /mcp-server/node_modules
RUN mkdir -p /app/data /app/uploads && cp -r /app/icons_seed /app/icons_seed_init
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY backend/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 4000
+642
View File
@@ -8,6 +8,8 @@
"name": "crowdlending-backend",
"version": "0.1.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.123.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
@@ -31,6 +33,27 @@
"nodemon": "^3.1.14"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz",
"integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
@@ -79,6 +102,15 @@
"openapi-types": ">=7"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -89,6 +121,18 @@
"tslib": "^2.4.0"
}
},
"node_modules/@hono/node-server": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
"integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -563,6 +607,419 @@
"node": ">=18"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
@@ -647,6 +1104,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -696,6 +1159,23 @@
}
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1379,6 +1859,27 @@
"node": ">= 0.6"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1455,6 +1956,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
@@ -1729,6 +2236,15 @@
"node": ">=16.0.0"
}
},
"node_modules/hono": {
"version": "4.13.5",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1800,6 +2316,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1864,6 +2389,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -1891,6 +2422,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jose": {
"version": "6.2.10",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz",
"integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@@ -1913,12 +2453,31 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -2490,6 +3049,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -2691,6 +3259,55 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/router/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2993,6 +3610,16 @@
"node": ">=10"
}
},
"node_modules/standardwebhooks": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -3191,6 +3818,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3390,6 +4023,15 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
}
}
}
+4
View File
@@ -11,6 +11,9 @@
"db:seed": "node src/db/seed.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.123.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"@mozilla/readability": "^0.6.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
@@ -18,6 +21,7 @@
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsdom": "^29.1.1",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
+113
View File
@@ -0,0 +1,113 @@
/**
* ai-seed-provider.js — Configure un fournisseur IA en ligne de commande, en
* attendant le back-office admin (Task future : CRUD ai_providers dans
* Admin.jsx). Usage (depuis le dossier backend/) :
*
* node scripts/ai-seed-provider.js --email vous@exemple.fr --type anthropic \
* --model claude-sonnet-4-5 --key sk-ant-xxxxx [--nom "Anthropic (test)"]
*
* Effets :
* - crée/met à jour un fournisseur ai_providers (nom sert de clé d'upsert),
* le marque is_default=1 (et retire ce flag des autres fournisseurs)
* - active ai_config.actif (interrupteur global) — sauf --no-activate
* - active users.ai_enabled pour l'email donné — sauf --no-user-enable
*
* Nécessite AI_ENC_KEY dans .env (voir .env.example) — sinon échoue avec un
* message explicite (chiffrement de la clé API du fournisseur).
*/
import 'dotenv/config';
import db from '../src/db/index.js';
import { encryptSecret } from '../src/ai/crypto.js';
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
args[key] = true; // flag booléen (ex. --no-activate)
} else {
args[key] = next;
i++;
}
}
}
return args;
}
const args = parseArgs(process.argv.slice(2));
const email = args.email;
const key = args.key;
const model = args.model;
const type = args.type || 'anthropic';
const nom = args.nom || (type === 'anthropic' ? 'Anthropic' : `${type} (via seed)`);
const baseUrl = args['base-url'] || null;
const activate = !args['no-activate'];
const enableUser = !args['no-user-enable'];
function fail(msg) {
console.error(`✗ ${msg}`);
process.exit(1);
}
if (!email) fail('--email requis (email du compte utilisateur à autoriser)');
if (!key) fail('--key requis (clé API du fournisseur, en clair — sera chiffrée avant stockage)');
if (!model) fail("--model requis (ex. claude-sonnet-4-5 pour l'adaptateur anthropic)");
if (!['anthropic', 'openai_compatible'].includes(type)) {
fail(`--type invalide (${type}) — attendu "anthropic" ou "openai_compatible"`);
}
if (type === 'openai_compatible' && !baseUrl) {
fail('--base-url requis pour --type openai_compatible (ex. https://openrouter.ai/api/v1)');
}
const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email);
if (!user) fail(`Aucun utilisateur avec l'email "${email}"`);
let apiKeyChiffree;
try {
apiKeyChiffree = encryptSecret(key);
} catch (e) {
fail(e.message);
}
const tx = db.transaction(() => {
db.exec('UPDATE ai_providers SET is_default = 0');
const existing = db.prepare('SELECT id FROM ai_providers WHERE nom = ?').get(nom);
let providerId;
if (existing) {
db.prepare(`
UPDATE ai_providers
SET type = ?, base_url = ?, modele = ?, api_key_chiffree = ?, actif = 1, is_default = 1, updated_at = datetime('now')
WHERE id = ?
`).run(type, baseUrl, model, apiKeyChiffree, existing.id);
providerId = existing.id;
console.log(`✓ Fournisseur "${nom}" mis à jour (id=${providerId})`);
} else {
const info = db.prepare(`
INSERT INTO ai_providers (nom, type, base_url, modele, api_key_chiffree, actif, is_default)
VALUES (?, ?, ?, ?, ?, 1, 1)
`).run(nom, type, baseUrl, model, apiKeyChiffree);
providerId = info.lastInsertRowid;
console.log(`✓ Fournisseur "${nom}" créé (id=${providerId})`);
}
if (activate) {
db.prepare(`
UPDATE ai_config SET actif = 1, provider_defaut_id = ?, updated_at = datetime('now') WHERE id = 1
`).run(providerId);
console.log('✓ Interrupteur global IA activé (ai_config.actif = 1)');
}
if (enableUser) {
db.prepare('UPDATE users SET ai_enabled = 1 WHERE id = ?').run(user.id);
console.log(`✓ Accès IA activé pour ${user.email}`);
}
});
tx();
console.log('\nPrêt à tester : GET /api/ai/status devrait répondre { enabled: true } pour cet utilisateur,');
console.log("puis POST /api/ai/chat avec un en-tête X-Investisseur-Id valide et un body { messages: [...] }.");
+107
View File
@@ -0,0 +1,107 @@
/**
* conversationHistory.js — Persistance des conversations de l'assistant IA
* (tables ai_conversations / ai_messages, migration dans db/index.js).
*
* Plusieurs conversations nommées par couple (user_id, investisseur_id) —
* même granularité de scope que le reste de l'application (cf.
* middleware/investisseurScope.js). Le client n'envoie que le dernier
* message utilisateur et l'id de conversation (ou aucun id pour en créer
* une nouvelle) ; c'est ce module qui reconstitue l'historique complet à
* chaque appel au modèle (voir routes/ai.js).
*/
import db from '../db/index.js';
const DEFAULT_LIMIT = 50;
function serializeConversation(row) {
if (!row) return null;
return { id: row.id, titre: row.titre, createdAt: row.created_at, updatedAt: row.updated_at };
}
/** Liste des conversations d'un utilisateur/investisseur, les plus
* récemment actives en premier. */
export function listConversations(userId, investisseurId, limit = 100) {
const rows = db.prepare(`
SELECT id, titre, created_at, updated_at
FROM ai_conversations
WHERE user_id = ? AND investisseur_id IS ?
ORDER BY updated_at DESC
LIMIT ?
`).all(userId, investisseurId, limit);
return rows.map(serializeConversation);
}
/** Une conversation précise, uniquement si elle appartient bien à ce
* couple user/investisseur (sinon null — jamais d'accès croisé). */
export function getConversation(userId, investisseurId, conversationId) {
const row = db.prepare(`
SELECT id, titre, created_at, updated_at
FROM ai_conversations WHERE id = ? AND user_id = ? AND investisseur_id IS ?
`).get(conversationId, userId, investisseurId);
return serializeConversation(row);
}
/** Crée une nouvelle conversation (titre dérivé du premier message côté
* routes/ai.js) et la retourne. */
export function createConversation(userId, investisseurId, titre) {
const { lastInsertRowid } = db.prepare(`
INSERT INTO ai_conversations (user_id, investisseur_id, titre) VALUES (?, ?, ?)
`).run(userId, investisseurId, titre || 'Nouvelle conversation');
return getConversation(userId, investisseurId, lastInsertRowid);
}
/** Supprime une conversation (et ses messages, ON DELETE CASCADE) si elle
* appartient à ce couple user/investisseur. Retourne true si une ligne a
* bien été supprimée. */
export function deleteConversation(userId, investisseurId, conversationId) {
const info = db.prepare(`
DELETE FROM ai_conversations WHERE id = ? AND user_id = ? AND investisseur_id IS ?
`).run(conversationId, userId, investisseurId);
return info.changes > 0;
}
/** Historique d'une conversation (ordre chronologique croissant), au
* format {role, content, tools:string[]} — tools est toujours [] pour
* role:'user'. Aucune vérification de propriété ici : à faire par
* l'appelant via getConversation() avant d'appeler cette fonction. */
export function getHistory(conversationId, limit = DEFAULT_LIMIT) {
const rows = db.prepare(`
SELECT role, content, outils_appeles, created_at
FROM ai_messages
WHERE conversation_id = ?
ORDER BY id DESC
LIMIT ?
`).all(conversationId, limit);
return rows.reverse().map((r) => ({
role: r.role,
content: r.content,
tools: r.outils_appeles ? JSON.parse(r.outils_appeles) : [],
createdAt: r.created_at,
}));
}
/** Enregistre un message dans une conversation et met à jour sa date
* d'activité (pour le tri par récence dans la liste). toolsUsed (tableau
* de noms d'outil ou null) n'a de sens que pour role:'assistant'.
* Best-effort : ne lève jamais (un échec de persistance ne doit pas
* casser la conversation). */
export function appendMessage(conversationId, userId, investisseurId, role, content, toolsUsed = null) {
try {
db.prepare(`
INSERT INTO ai_messages (conversation_id, user_id, investisseur_id, role, content, outils_appeles)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
conversationId,
userId,
investisseurId,
role,
content,
toolsUsed && toolsUsed.length ? JSON.stringify(toolsUsed) : null,
);
db.prepare(`UPDATE ai_conversations SET updated_at = datetime('now') WHERE id = ?`).run(conversationId);
} catch (e) {
console.error('[ai/conversationHistory] échec de persistance (ignoré) :', e.message);
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* crypto.js — Chiffrement au repos des clés API des fournisseurs IA
* (ai_providers.api_key_chiffree).
*
* Contrairement aux clés API internes de l'application (api_keys.key_hash,
* à sens unique — on ne fait que comparer un hash), une clé de fournisseur
* IA (Anthropic, OpenAI, OpenRouter...) doit être renvoyée EN CLAIR au
* fournisseur à chaque appel : elle est donc chiffrée de façon réversible
* (AES-256-GCM), jamais hashée.
*
* AI_ENC_KEY (obligatoire dès qu'un fournisseur IA est configuré) : chaîne
* hexadécimale de 64 caractères (32 octets), générée avec `openssl rand
* -hex 32` — voir .env.example. Ne jamais commiter cette clé ; la faire
* tourner invalide tous les fournisseurs déjà enregistrés (à re-saisir).
*/
import crypto from 'node:crypto';
const ALGO = 'aes-256-gcm';
const IV_LENGTH = 12; // taille recommandée pour GCM
function getKey() {
const hex = process.env.AI_ENC_KEY;
if (!hex) {
throw new Error(
"AI_ENC_KEY manquant — générez une clé avec `openssl rand -hex 32` et ajoutez-la à .env avant de configurer un fournisseur IA."
);
}
const key = Buffer.from(hex, 'hex');
if (key.length !== 32) {
throw new Error(
'AI_ENC_KEY doit être une chaîne hexadécimale de 64 caractères (32 octets) — générée avec `openssl rand -hex 32`.'
);
}
return key;
}
/**
* Chiffre une chaîne (typiquement une clé API de fournisseur IA) pour
* stockage en base. Format stocké : "<iv_hex>:<authTag_hex>:<ciphertext_hex>".
*/
export function encryptSecret(plaintext) {
const key = getKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${ciphertext.toString('hex')}`;
}
/** Déchiffre une valeur produite par encryptSecret(). Lève si AI_ENC_KEY a
* changé depuis le chiffrement (authTag invalide) ou si le format stocké
* est corrompu. */
export function decryptSecret(stored) {
const key = getKey();
const parts = String(stored).split(':');
if (parts.length !== 3) {
throw new Error('Valeur chiffrée malformée (attendu "iv:authTag:ciphertext")');
}
const [ivHex, authTagHex, ciphertextHex] = parts;
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextHex, 'hex')),
decipher.final(),
]);
return plaintext.toString('utf8');
}
+46
View File
@@ -0,0 +1,46 @@
/**
* internalApiKey.js — Clé API interne, éphémère, pour l'assistant IA embarqué.
*
* Donne au chat un accès à /api/v1 strictement identique à un client MCP
* externe (même middleware requireApiKey, même scope investisseur), sans
* dupliquer cette logique — voir mcpBridge.js pour l'usage.
*
* api_keys ne stocke normalement qu'un hash à sens unique (voir
* routes/apiKeys.js : "la valeur en clair n'est renvoyée qu'une fois"), donc
* impossible de relire une clé déjà créée pour la réutiliser. Plutôt que
* d'introduire un second mécanisme de stockage réversible, on génère une
* clé neuve à chaque requête POST /api/ai/chat (surcoût négligeable : un
* INSERT SQLite), utilisée uniquement pour la durée de cette requête, puis
* révoquée. Jamais affichée à l'utilisateur (colonne internal=1, filtrée de
* GET /api/api-keys).
*/
import db from '../db/index.js';
import { generateKey } from '../routes/apiKeys.js';
/** Crée une clé API interne à usage unique pour (userId, investisseurId).
* Retourne { id, key } — `key` est la valeur en clair à passer en
* en-tête X-API-Key, jamais stockée ailleurs que dans ce retour. */
export function createEphemeralInternalApiKey(userId, investisseurId) {
const { full, hash, prefix } = generateKey();
const info = db.prepare(`
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all, internal)
VALUES (?, ?, 'Assistant IA (interne)', ?, ?, 'read', 0, 1)
`).run(userId, investisseurId, prefix, hash);
return { id: info.lastInsertRowid, key: full };
}
/** Révoque (supprime) une clé interne créée par createEphemeralInternalApiKey.
* À appeler systématiquement en fin de requête (bloc finally), y compris en
* cas d'erreur — best-effort, ne doit jamais faire échouer l'appelant. Le
* filtre `internal = 1` est une double sécurité : cette fonction ne peut
* jamais supprimer une vraie clé créée par l'utilisateur. */
export function revokeInternalApiKey(id) {
try {
db.prepare('DELETE FROM api_keys WHERE id = ? AND internal = 1').run(id);
} catch (e) {
console.error('[ai/internalApiKey] échec de révocation (ignoré) :', e.message);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* mcpBridge.js — Pont MCP in-process vers les mêmes outils que le serveur
* MCP externe (mcp-server/tools.js), réutilisés TELS QUELS (import direct
* depuis mcp-server/, jamais modifiés) via un client MCP standard connecté
* en mémoire (InMemoryTransport) — donc un comportement strictement
* identique à ce que voit un client MCP externe (Claude Desktop...), sans
* passer par le réseau, et sans aucun risque pour le serveur MCP externe en
* production (ce fichier ne le touche pas).
*
* Chaque appel crée son propre couple client/serveur MCP, léger, sans état
* partagé entre requêtes.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { registerDataTools } from '../../../mcp-server/tools.js';
/**
* Crée un client MCP connecté aux 9 outils de lecture seule du portefeuille,
* exécutés via `apiGet` (typiquement une clé API interne éphémère — voir
* internalApiKey.js).
*
* @param {(path: string, params?: object) => Promise<any>} apiGet
* @returns {Promise<Client>} Client MCP prêt pour listTools()/callTool().
* Appeler client.close() en fin d'usage.
*/
export async function createAiToolsClient(apiGet) {
const server = new McpServer({ name: 'crowdlending-ai-embarque', version: '0.1.0' });
registerDataTools(server, { apiGet });
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'crowdlending-ai-embarque-client', version: '0.1.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
return client;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* providerConfig.js — Résolution du fournisseur IA actif par défaut.
* Seul endroit qui définit cette règle (is_default=1 en priorité, sinon le
* plus ancien actif) — réutilisé par le middleware d'accès (aiAccess.js) et
* par la route de chat (routes/ai.js), pour ne jamais diverger.
*/
import db from '../db/index.js';
/** @returns {object|undefined} La ligne ai_providers par défaut active, ou
* undefined si aucun fournisseur actif n'est configuré. */
export function getDefaultActiveProvider() {
return db
.prepare('SELECT * FROM ai_providers WHERE actif = 1 ORDER BY is_default DESC, id ASC LIMIT 1')
.get();
}
+266
View File
@@ -0,0 +1,266 @@
/**
* providers/anthropic.js — Adaptateur fournisseur IA "anthropic" (natif,
* SDK officiel @anthropic-ai/sdk — pas de base_url, contrairement à
* l'adaptateur openai_compatible à venir).
*
* Boucle d'usage d'outils : tant que le modèle répond stop_reason
* 'tool_use', on exécute les outils demandés via le pont MCP (mcpBridge.js
* — les 9 mêmes outils en lecture seule que le serveur MCP externe) et on
* relance un tour avec le(s) tool_result ajoutés à l'historique, jusqu'à
* 'end_turn' ou MAX_TOOL_ITERATIONS (garde-fou anti-boucle).
*/
import Anthropic from '@anthropic-ai/sdk';
import { mcpToolsToAnthropic } from '../toolSchema.js';
const MAX_TOOL_ITERATIONS = 8;
const DEFAULT_MAX_TOKENS = 4096;
/**
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model Ex. "claude-sonnet-4-5" (configuré par l'admin, ai_providers.modele)
* @param {string} [opts.systemPrompt]
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
* Historique de conversation en texte simple (converti ici en blocs Anthropic).
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
* Client MCP connecté (voir mcpBridge.js).
*
* @yields {
* {type:'text_delta', text:string} |
* {type:'tool_call', name:string, input:object} |
* {type:'tool_result', name:string, isError:boolean} |
* {type:'done', usage:{input_tokens:number, output_tokens:number}, toolsUsed:string[]} |
* {type:'error', message:string}
* }
*/
export async function* streamChat({ apiKey, model, systemPrompt, messages, mcpClient }) {
const anthropic = new Anthropic({ apiKey });
const { tools: mcpTools } = await mcpClient.listTools();
const tools = mcpToolsToAnthropic(mcpTools);
// Historique mutable au format API Anthropic (content = tableau de blocs).
let history = messages.map((m) => ({
role: m.role,
content: [{ type: 'text', text: m.content }],
}));
let totalInputTokens = 0;
let totalOutputTokens = 0;
const toolsUsed = [];
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let stream;
try {
stream = anthropic.messages.stream({
model,
max_tokens: DEFAULT_MAX_TOKENS,
system: systemPrompt,
messages: history,
tools,
});
} catch (e) {
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
return;
}
try {
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
yield { type: 'text_delta', text: event.delta.text };
}
}
} catch (e) {
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
return;
}
let finalMessage;
try {
finalMessage = await stream.finalMessage();
} catch (e) {
yield { type: 'error', message: `Échec de récupération de la réponse : ${e.message}` };
return;
}
totalInputTokens += finalMessage.usage?.input_tokens || 0;
totalOutputTokens += finalMessage.usage?.output_tokens || 0;
history.push({ role: 'assistant', content: finalMessage.content });
if (finalMessage.stop_reason !== 'tool_use') {
yield {
type: 'done',
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
toolsUsed,
};
return;
}
const toolUseBlocks = finalMessage.content.filter((b) => b.type === 'tool_use');
const toolResultBlocks = [];
for (const block of toolUseBlocks) {
yield { type: 'tool_call', name: block.name, input: block.input };
toolsUsed.push(block.name);
let resultText;
let isError = false;
try {
const result = await mcpClient.callTool({ name: block.name, arguments: block.input });
isError = !!result.isError;
resultText =
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
} catch (e) {
isError = true;
resultText = `Erreur : ${e.message}`;
}
yield { type: 'tool_result', name: block.name, isError };
toolResultBlocks.push({
type: 'tool_result',
tool_use_id: block.id,
content: resultText,
is_error: isError,
});
}
history.push({ role: 'user', content: toolResultBlocks });
}
yield {
type: 'error',
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
};
}
/**
* testConnection() — appel minimal, sans streaming ni outils, pour
* vérifier depuis l'admin qu'une clé API + modèle fonctionnent (bouton
* « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
*/
export async function testConnection({ apiKey, model }) {
const anthropic = new Anthropic({ apiKey });
const started = Date.now();
const message = await anthropic.messages.create({
model,
max_tokens: 16,
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
});
const reply = (message.content || [])
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
.trim() || '(réponse vide)';
return { reply, durationMs: Date.now() - started, usage: message.usage };
}
/**
* lookupInstitution() — recherche web automatique (outil natif Anthropic
* "web_search", exécuté côté serveur Anthropic) pour pré-remplir une fiche
* institution depuis l'admin (icône "baguette magique", InstitutionModal —
* demande Olivier 27/09/26) : au minimum une description succincte et le
* pays de domiciliation (code ISO 3166-1 alpha-2, cf. CountrySelect.jsx
* côté frontend).
*
* Contrairement à testConnection() (ping minimal, sans outil), cet appel
* n'est pas en streaming : pour un outil serveur, Anthropic exécute la/les
* recherche(s) et le tour de lecture des résultats en interne, la réponse
* finale (texte, éventuellement précédée de blocs server_tool_use /
* web_search_tool_result) arrivant en un seul message — donc pas de boucle
* côté client comme dans streamChat(), mais un appel qui peut prendre
* plusieurs secondes.
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model
* @param {string} opts.nom Nom de l'institution à rechercher
* @param {string} [opts.url] URL du site officiel, si déjà connue (aide la recherche)
* @returns {Promise<{description:string, domiciliation:string|null, durationMs:number, sourcesCount:number}>}
*/
export async function lookupInstitution({ apiKey, model, nom, url, categories }) {
const anthropic = new Anthropic({ apiKey });
const started = Date.now();
// `categories` : liste fermée [{id, nom}] issue de categories_institutions (fournie par
// l'appelant, cf. institutionsReferentiel.js) — on demande au modèle de choisir EXACTEMENT
// parmi ces libellés plutôt que d'en inventer, pour pouvoir les remapper en id de façon fiable
// (retrofit du 28/09/26, demande Olivier : enrichir aussi URL/domiciliation/catégories, pas
// seulement la description).
const categoryNames = (categories || []).map((c) => c.nom);
const prompt = [
`Recherche sur le web des informations fiables et à jour sur l'institution financière suivante : "${nom}"${url ? ` (site officiel présumé : ${url})` : ''}.`,
`C'est potentiellement une banque, néobanque, courtier, assureur, société de gestion ou établissement de paiement.`,
categoryNames.length
? `Catégories possibles (choisis zéro, une ou plusieurs valeurs EXACTEMENT parmi cette liste, sans en inventer d'autres) : ${categoryNames.join(', ')}.`
: '',
`Réponds UNIQUEMENT avec un objet JSON valide, sans aucun texte autour, au format exact :`,
`{"description": "<description succincte en français, 1 à 3 phrases : activité, statut/régulateur si pertinent>", "domiciliation": "<code pays ISO 3166-1 alpha-2 du siège social, ex. FR, LU, DE — null si inconnu>", "url": "<URL du site officiel, https://... — null si non trouvée>", "categories": [<0 à N valeurs issues STRICTEMENT de la liste de catégories ci-dessus>]}`,
].filter(Boolean).join('\n');
const message = await anthropic.messages.create({
model,
max_tokens: 1024,
tools: [{ type: 'web_search_20260318', name: 'web_search', max_uses: 4 }],
messages: [{ role: 'user', content: prompt }],
});
// Capturé avant le parsing JSON (et rattaché à toute erreur levée plus bas via
// err.usage/err.toolCalled) afin que l'appelant puisse journaliser dans
// ai_usage_log — au même titre que le chat utilisateur (routes/ai.js) — même
// quand la réponse du modèle n'est pas exploitable : l'appel API a bien
// consommé des tokens (et potentiellement déclenché des recherches web).
const usage = { input_tokens: message.usage?.input_tokens ?? null, output_tokens: message.usage?.output_tokens ?? null };
const toolCalled = (message.content || []).some((b) => b.type === 'server_tool_use' || b.type === 'web_search_tool_result');
const sourcesCount = (message.content || []).filter((b) => b.type === 'web_search_tool_result').length;
const textBlocks = (message.content || []).filter((b) => b.type === 'text').map((b) => b.text);
const fullText = textBlocks.join('\n').trim();
let parsed;
try {
const jsonMatch = fullText.match(/\{[\s\S]*\}/);
parsed = JSON.parse(jsonMatch ? jsonMatch[0] : fullText);
} catch (e) {
const err = new Error(`Réponse du modèle non exploitable (JSON attendu) : ${fullText.slice(0, 200) || '(vide)'}`);
err.usage = usage; err.toolCalled = toolCalled; err.sourcesCount = sourcesCount;
throw err;
}
const description = typeof parsed.description === 'string' ? parsed.description.trim() : '';
let domiciliation = typeof parsed.domiciliation === 'string' ? parsed.domiciliation.trim().toUpperCase() : null;
if (domiciliation && !/^[A-Z]{2}$/.test(domiciliation)) domiciliation = null;
let siteUrl = typeof parsed.url === 'string' ? parsed.url.trim() : '';
if (!/^https?:\/\//i.test(siteUrl)) siteUrl = '';
// Rematch strict par nom (insensible à la casse) contre la liste fournie — un libellé
// halluciné par le modèle (hors liste, faute de frappe...) est simplement ignoré plutôt que
// de faire planter la requête ou de créer un id inexistant.
const rawCategories = Array.isArray(parsed.categories) ? parsed.categories : [];
const categoryIds = [];
for (const raw of rawCategories) {
if (typeof raw !== 'string') continue;
const match = (categories || []).find((c) => c.nom.toLowerCase() === raw.trim().toLowerCase());
if (match && !categoryIds.includes(match.id)) categoryIds.push(match.id);
}
if (!description) {
const err = new Error("Le modèle n'a renvoyé aucune description exploitable.");
err.usage = usage; err.toolCalled = toolCalled; err.sourcesCount = sourcesCount;
throw err;
}
return {
description, domiciliation, url: siteUrl || null, categoryIds,
durationMs: Date.now() - started, sourcesCount, toolCalled, usage,
};
}
@@ -0,0 +1,236 @@
/**
* providers/openaiCompatible.js — Adaptateur fournisseur IA
* "openai_compatible" : OpenAI, Mistral, Groq, un LLM local, ou OpenRouter
* (ai_providers.base_url pointé vers https://openrouter.ai/api/v1).
*
* Pas de SDK dédié : appel HTTP direct en POST {base_url}/chat/completions
* avec stream:true, et parsing manuel des trames SSE renvoyées par l'API
* (format `data: {...}\n\n`, terminé par `data: [DONE]`) — même technique
* que frontend/src/api.js:stream() côté client, pour rester cohérent avec
* le reste du projet plutôt que d'ajouter une dépendance SDK OpenAI.
*
* Boucle d'usage d'outils : même esprit que providers/anthropic.js (jusqu'à
* MAX_TOOL_ITERATIONS), adaptée au format "function calling" OpenAI : les
* tool_calls arrivent en deltas fragmentés (nom et arguments JSON accumulés
* morceau par morceau), puis sont exécutés via le pont MCP et renvoyés en
* messages role:"tool".
*
* NB : le support effectif du function calling en streaming varie selon le
* modèle/fournisseur derrière base_url (tous les modèles OpenRouter ne le
* supportent pas) — non testé en conditions réelles dans cette session,
* faute de clé API disponible (voir project_ia_embarquee.md, mémoire projet).
*/
import { mcpToolsToOpenAI } from '../toolSchema.js';
const MAX_TOOL_ITERATIONS = 8;
const DEFAULT_MAX_TOKENS = 4096;
/**
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.baseUrl Ex. "https://openrouter.ai/api/v1" (ai_providers.base_url)
* @param {string} opts.model Ex. "anthropic/claude-sonnet-4.5" (ai_providers.modele)
* @param {string} [opts.systemPrompt]
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
*
* @yields Mêmes événements que providers/anthropic.js#streamChat.
*/
export async function* streamChat({ apiKey, baseUrl, model, systemPrompt, messages, mcpClient }) {
const { tools: mcpTools } = await mcpClient.listTools();
const tools = mcpToolsToOpenAI(mcpTools);
let history = [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
...messages.map((m) => ({ role: m.role, content: m.content })),
];
let totalInputTokens = 0;
let totalOutputTokens = 0;
const toolsUsed = [];
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: history,
tools,
stream: true,
max_tokens: DEFAULT_MAX_TOKENS,
}),
});
} catch (e) {
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
return;
}
if (!res.ok || !res.body) {
let detail = '';
try { detail = await res.text(); } catch { /* noop */ }
yield { type: 'error', message: `Erreur API (${res.status}) : ${detail || res.statusText}` };
return;
}
// Accumulateurs pour reconstruire la réponse assistant à partir des
// deltas SSE (le contenu texte ET les tool_calls arrivent fragmentés).
let assistantContent = '';
const toolCallsAcc = new Map(); // index -> { id, name, argsText }
let finishReason = null;
let usage = null;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
let parsed;
try { parsed = JSON.parse(data); } catch { continue; }
if (parsed.usage) usage = parsed.usage;
const choice = parsed.choices?.[0];
if (!choice) continue;
const delta = choice.delta || {};
if (delta.content) {
assistantContent += delta.content;
yield { type: 'text_delta', text: delta.content };
}
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!toolCallsAcc.has(idx)) toolCallsAcc.set(idx, { id: tc.id || `call_${idx}`, name: '', argsText: '' });
const acc = toolCallsAcc.get(idx);
if (tc.id) acc.id = tc.id;
if (tc.function?.name) acc.name += tc.function.name;
if (tc.function?.arguments) acc.argsText += tc.function.arguments;
}
}
if (choice.finish_reason) finishReason = choice.finish_reason;
}
}
} catch (e) {
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
return;
}
if (usage) {
totalInputTokens += usage.prompt_tokens || 0;
totalOutputTokens += usage.completion_tokens || 0;
}
if (finishReason !== 'tool_calls' || toolCallsAcc.size === 0) {
history.push({ role: 'assistant', content: assistantContent });
yield {
type: 'done',
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
toolsUsed,
};
return;
}
const toolCalls = [...toolCallsAcc.values()];
history.push({
role: 'assistant',
content: assistantContent || null,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.argsText },
})),
});
for (const tc of toolCalls) {
let input = {};
try { input = tc.argsText ? JSON.parse(tc.argsText) : {}; }
catch { /* arguments JSON malformés renvoyés par le modèle — outil appelé avec {} */ }
yield { type: 'tool_call', name: tc.name, input };
toolsUsed.push(tc.name);
let resultText;
let isError = false;
try {
const result = await mcpClient.callTool({ name: tc.name, arguments: input });
isError = !!result.isError;
resultText =
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
} catch (e) {
isError = true;
resultText = `Erreur : ${e.message}`;
}
yield { type: 'tool_result', name: tc.name, isError };
history.push({ role: 'tool', tool_call_id: tc.id, content: resultText });
}
}
yield {
type: 'error',
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
};
}
/**
* testConnection() — appel minimal, sans streaming ni outils, pour
* vérifier depuis l'admin qu'une clé API + base_url + modèle fonctionnent
* (bouton « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.baseUrl
* @param {string} opts.model
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
*/
export async function testConnection({ apiKey, baseUrl, model }) {
const started = Date.now();
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model,
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
max_tokens: 16,
stream: false,
}),
signal: AbortSignal.timeout(20000),
});
} catch (e) {
throw new Error(`Impossible de joindre ${baseUrl} : ${e.message}`);
}
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
if (!res.ok) {
const msg = body?.error?.message || body?.error || res.statusText || `Erreur HTTP ${res.status}`;
throw new Error(`Erreur API (${res.status}) : ${msg}`);
}
const reply = body?.choices?.[0]?.message?.content?.trim() || '(réponse vide)';
return { reply, durationMs: Date.now() - started, usage: body?.usage };
}
+39
View File
@@ -0,0 +1,39 @@
/**
* toolSchema.js — Conversion du format d'outil MCP (client.listTools(), voir
* mcpBridge.js) vers les formats `tools` attendus par les deux adaptateurs :
* API Messages Anthropic (mcpToolsToAnthropic) et function calling OpenAI
* (mcpToolsToOpenAI, utilisé par providers/openaiCompatible.js — OpenAI,
* Mistral, Groq, LLM local, OpenRouter…).
*/
/** Retire les champs de méta-schéma (ex. $schema) que le SDK MCP ajoute mais
* qu'Anthropic n'attend pas dans input_schema. */
function cleanInputSchema(schema) {
if (!schema || typeof schema !== 'object') return { type: 'object', properties: {} };
const { $schema, ...rest } = schema;
return rest;
}
/** Convertit la liste d'outils MCP (résultat de client.listTools().tools) au
* format `tools` de l'API Messages Anthropic. */
export function mcpToolsToAnthropic(mcpTools) {
return mcpTools.map((t) => ({
name: t.name,
description: t.description || '',
input_schema: cleanInputSchema(t.inputSchema),
}));
}
/** Convertit la liste d'outils MCP au format `tools` de function calling
* OpenAI (utilisé aussi par OpenRouter, Mistral, Groq, et la plupart des
* LLM locaux compatibles OpenAI). */
export function mcpToolsToOpenAI(mcpTools) {
return mcpTools.map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description || '',
parameters: cleanInputSchema(t.inputSchema),
},
}));
}
+2178 -5
View File
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -10,6 +10,7 @@ import os from 'node:os';
import { fileURLToPath } from 'node:url';
import db from '../db/index.js';
import { createZip } from '../utils/zip.js';
import { buildAllDocumentsZipEntries } from '../routes/documents.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = process.env.DATA_DIR
@@ -85,13 +86,20 @@ export async function runAutoExport() {
}
}
// Fichiers documents (tous utilisateurs) — la base SQLite ci-dessus contient
// déjà les lignes `documents`, mais pas les fichiers physiques qu'elles
// référencent ; cf. demande Olivier 29/08/26. Même helper que la route
// manuelle GET /api/admin/export-full, pour ne pas dupliquer cette logique.
const documentEntries = buildAllDocumentsZipEntries();
for (const entry of documentEntries) entries.push(entry);
const zipBuf = createZip(entries);
fs.mkdirSync(exportsDir, { recursive: true });
fs.writeFileSync(path.join(exportsDir, filename), zipBuf);
purgeOldExports();
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Durée : ${elapsed}s`;
const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Documents : ${documentEntries.length} | Durée : ${elapsed}s`;
writeLog({ status: 'ok', nbChanges: 1, details });
console.log(`[autoExport] Export terminé en ${elapsed}s → ${filename}`);
} catch (e) {
+69
View File
@@ -0,0 +1,69 @@
/**
* aiAccess.js — Contrôle d'accès à l'assistant IA embarqué, à 3 niveaux :
* 1. Interrupteur global (ai_config.actif)
* 2. Accès par utilisateur (users.ai_enabled, décidé par l'admin)
* 3. Au moins un fournisseur actif configuré (ai_providers)
* … plus un 4e contrôle distinct, le quota (checkAiQuota) : contrairement
* aux 3 niveaux ci-dessus qui sont du tout-ou-rien, le quota est un
* comptage qui varie dans la journée — géré séparément (429, pas 403) et
* appelé explicitement dans routes/ai.js plutôt que via un middleware, pour
* garder requireAiAccess simple et testable indépendamment.
*
* getAiAccessStatus() est la seule fonction qui définit la règle — utilisée
* à la fois par GET /api/ai/status (qui doit répondre même à un utilisateur
* non autorisé, pour que le frontend sache masquer le chat) et par le
* middleware requireAiAccess ci-dessous (à utiliser après requireAuth sur
* les routes qui appellent réellement le modèle, ex. POST /api/ai/chat).
*/
import db from '../db/index.js';
import { getDefaultActiveProvider } from '../ai/providerConfig.js';
export function getAiAccessStatus(userId) {
const config = db.prepare('SELECT actif FROM ai_config WHERE id = 1').get();
const globalActif = !!(config && config.actif);
const user = db.prepare('SELECT ai_enabled FROM users WHERE id = ?').get(userId);
const userActif = !!(user && user.ai_enabled);
const providerDisponible = !!getDefaultActiveProvider();
return {
enabled: globalActif && userActif && providerDisponible,
globalActif,
userActif,
providerDisponible,
};
}
/** À utiliser après requireAuth sur les routes qui font réellement appel au
* modèle. Ne PAS l'utiliser sur GET /api/ai/status — voir plus haut. */
export function requireAiAccess(req, res, next) {
const status = getAiAccessStatus(req.user.id);
if (!status.enabled) {
let message = "Fonctionnalité IA désactivée.";
if (!status.globalActif) message = "L'assistant IA n'est pas activé sur cette instance.";
else if (!status.userActif) message = "Vous n'avez pas accès à l'assistant IA — contactez un administrateur.";
else if (!status.providerDisponible) message = "Aucun fournisseur IA actif n'est configuré.";
return res.status(403).json({ error: message });
}
next();
}
/** Vérifie le quota quotidien global (ai_config.quota_quotidien — nombre de
* requêtes/jour/utilisateur, NULL = illimité). Compte les lignes déjà
* enregistrées aujourd'hui dans ai_usage_log pour cet utilisateur (succès
* et échecs confondus — une requête qui a consommé un tour de modèle
* compte, qu'elle ait abouti ou non). Retourne {allowed, used, limit}. */
export function checkAiQuota(userId) {
const config = db.prepare('SELECT quota_quotidien FROM ai_config WHERE id = 1').get();
const limit = config?.quota_quotidien ?? null;
if (!limit) return { allowed: true, used: 0, limit: null };
const { n } = db.prepare(`
SELECT COUNT(*) AS n FROM ai_usage_log
WHERE user_id = ? AND date(created_at) = date('now')
`).get(userId);
return { allowed: n < limit, used: n, limit };
}
+38
View File
@@ -0,0 +1,38 @@
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 de type "crowdlending"
* (le singleton — cf. workspaces.type, 19/09/26) — jamais d'échec bloquant, pour rester
* transparent quel que soit le nombre de workspaces existants.
*/
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 type = 'crowdlending'").get();
return cl ? cl.id : null;
}
+222 -16
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import db from '../db/index.js';
import db, { grantDefaultWorkspace } from '../db/index.js';
import Database from 'better-sqlite3';
import { HttpError } from '../middleware/errorHandler.js';
import { checkStatutsRetard } from '../jobs/autoStatut.js';
@@ -14,6 +14,7 @@ import { runAutoExport } from '../jobs/autoExport.js';
import { audit } from '../utils/audit.js';
import multer from 'multer';
import { createZip, readZip } from '../utils/zip.js';
import { deleteAllDocumentFilesForUser, buildAllDocumentsZipEntries } from './documents.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = process.env.DATA_DIR
@@ -64,13 +65,31 @@ const router = Router();
/* ── Utilisateurs ─────────────────────────────────────────────────────── */
/** Liste tous les utilisateurs */
/** Liste tous les utilisateurs, avec quelques compteurs d'usage par compte
* (plateformes, investissements, documents) — demande Olivier 29/08/26,
* colonnes affichées dans Administration > Utilisateurs. Sous-requêtes
* corrélées plutôt que JOIN + GROUP BY : chaque compte reste une ligne
* distincte sans risquer de doublons liés aux jointures multiples. */
router.get('/users', (req, res) => {
const users = db.prepare(`
SELECT id, email, display_name, role, email_verified, totp_enabled, status, created_at
SELECT
id, email, display_name, role, email_verified, totp_enabled, status, created_at, ai_enabled,
(SELECT COUNT(*) FROM plateformes p WHERE p.user_id = users.id) AS nb_plateformes,
(SELECT COUNT(*) FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE inv.user_id = users.id) AS nb_investissements,
(SELECT COUNT(*) FROM documents d WHERE d.user_id = users.id) AS nb_documents,
(SELECT COALESCE(SUM(d.taille_octets), 0) FROM documents d WHERE d.user_id = users.id) AS taille_documents,
(SELECT GROUP_CONCAT(workspace_id) FROM user_workspaces uw WHERE uw.user_id = users.id AND uw.granted_by_admin = 1) AS workspace_ids
FROM users
ORDER BY id ASC
`).all();
// workspace_ids : GROUP_CONCAT renvoie une chaîne "1,2" ou null — converti ici
// en tableau de nombres pour rester cohérent avec le format attendu côté
// frontend (ManageRightsModal), plutôt que de parser la chaîne à chaque endroit.
for (const u of users) {
u.workspace_ids = u.workspace_ids ? u.workspace_ids.split(',').map(Number) : [];
}
res.json(users);
});
@@ -119,6 +138,10 @@ router.post('/users', (req, res, next) => {
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
// Accorde l'accès au workspace socle (crowdlending), comme tout nouveau compte
// — cf. project_workspaces_transformation.md.
grantDefaultWorkspace(userId);
audit(req, { action: 'user_created', category: 'account', actorId: req.user.id, targetUserId: userId, details: { email: body.email, role: body.role, created_by_admin: true } });
res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role });
} catch (e) { next(e); }
@@ -144,6 +167,56 @@ router.patch('/users/:id/status', (req, res, next) => {
} catch (e) { next(e); }
});
/** Active / désactive l'accès à l'Assistant IA pour un utilisateur (décision admin) */
const PatchAiAccessSchema = z.object({
aiEnabled: z.boolean(),
});
router.patch('/users/:id/ai-access', (req, res, next) => {
try {
const { aiEnabled } = PatchAiAccessSchema.parse(req.body);
const targetId = Number(req.params.id);
const r = db.prepare("UPDATE users SET ai_enabled=?, updated_at=datetime('now') WHERE id=?")
.run(aiEnabled ? 1 : 0, targetId);
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
audit(req, { action: 'ai_access_changed', category: 'ai', actorId: req.user.id, targetUserId: targetId, details: { ai_enabled: aiEnabled } });
res.json({ id: targetId, aiEnabled });
} catch (e) { next(e); }
});
/** Définit les workspaces accordés à un utilisateur (octroi admin) — remplace
* intégralement l'ensemble accordé (granted_by_admin) par la liste fournie.
* L'activation personnelle (active_by_user) n'est jamais touchée pour un
* workspace déjà accordé, pour ne pas écraser un choix déjà fait par
* l'utilisateur ; elle est mise à 1 par défaut pour un nouvel octroi. */
const PatchWorkspacesSchema = z.object({
workspaceIds: z.array(z.number().int()),
});
router.patch('/users/:id/workspaces', (req, res, next) => {
try {
const { workspaceIds } = PatchWorkspacesSchema.parse(req.body);
const targetId = Number(req.params.id);
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId);
if (!target) throw new HttpError(404, 'Utilisateur introuvable');
const applyGrants = db.transaction(() => {
db.prepare('UPDATE user_workspaces SET granted_by_admin = 0 WHERE user_id = ?').run(targetId);
for (const wsId of workspaceIds) {
db.prepare(`
INSERT INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
VALUES (?, ?, 1, 1, datetime('now'))
ON CONFLICT(user_id, workspace_id) DO UPDATE SET granted_by_admin = 1, granted_at = datetime('now')
`).run(targetId, wsId);
}
});
applyGrants();
audit(req, { action: 'workspaces_changed', category: 'workspaces', actorId: req.user.id, targetUserId: targetId, details: { workspace_ids: workspaceIds } });
res.json({ id: targetId, workspaceIds });
} catch (e) { next(e); }
});
/** Modifie le rôle d'un utilisateur */
const PatchRoleSchema = z.object({
role: z.enum(['user', 'admin']),
@@ -176,6 +249,14 @@ router.delete('/users/:id', (req, res, next) => {
throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte');
}
const targetUser = db.prepare('SELECT email, display_name FROM users WHERE id = ?').get(targetId);
if (!targetUser) throw new HttpError(404, 'Utilisateur introuvable');
// Fichiers documents de l'utilisateur : à effacer AVANT le DELETE FROM users
// (les lignes `documents` seront supprimées en cascade par la contrainte
// ON DELETE CASCADE, mais une cascade SQLite n'efface jamais les fichiers
// du disque — il faut les lister pendant que le user existe encore).
deleteAllDocumentFilesForUser(targetId);
const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId);
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
audit(req, { action: 'user_deleted', category: 'account', actorId: req.user.id, details: { email: targetUser?.email, display_name: targetUser?.display_name } });
@@ -497,6 +578,11 @@ router.get('/export-full', (req, res, next) => {
}
}
// Fichiers documents (tous utilisateurs) — la base SQLite ci-dessus contient
// déjà les lignes `documents`, mais pas les fichiers physiques qu'elles
// référencent ; cf. demande Olivier 29/08/26.
entries.push(...buildAllDocumentsZipEntries());
const zipBuf = createZip(entries);
// Sauvegarde sur disque + purge
@@ -597,14 +683,22 @@ router.delete('/exports/:filename', (req, res, next) => {
* POST /api/admin/exports/:filename/restore
* Restaure l'environnement depuis un export stocké :
* 1. Sauvegarde l'état courant dans exports/ (filet de sécurité)
* 2. Copie les assets (logos, icons) immédiatement
* 2. Copie les assets (logos, icons, documents) immédiatement
* 3. Écrit la nouvelle DB dans {DB_PATH}.pending-restore
* 4. Répond au client, puis redémarre le processus (process.exit)
* → En prod (DATA_DIR défini), le restart policy Docker relance le conteneur
* → En prod (NODE_ENV=production), le restart policy Docker relance le conteneur
* qui applique le pending-restore au démarrage.
* → En local (DATA_DIR absent), aucun superviseur (node --watch / nodemon)
* ne relance automatiquement le process après un exit propre : on
* respawn nous-mêmes un process enfant détaché avant de quitter.
* → En local (NODE_ENV≠production — dev nodemon, ou tout environnement qui ne
* se déclare pas explicitement "production"), aucun superviseur externe ne
* relance automatiquement le process après un exit propre (nodemon n'auto-
* restart que sur changement de fichier) : on respawn nous-mêmes un process
* enfant détaché avant de quitter. NB (19/09/26, bug signalé par Olivier :
* "j'ai restauré, ça ne marche pas sans relancer le serveur à la main") :
* le signal utilisé ici était auparavant la présence de DATA_DIR, qui n'est
* PAS un indicateur fiable de "on tourne sous Docker avec restart policy" —
* le .env de dev local définit lui aussi DATA_DIR (pour organiser où vivent
* les données), donc la condition ne se déclenchait jamais en dev et le
* process restait mort jusqu'à un redémarrage manuel.
*/
router.post('/exports/:filename/restore', async (req, res, next) => {
const tmpDb = path.join(os.tmpdir(), `cl-pre-restore-${Date.now()}.db`);
@@ -642,7 +736,10 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
}, null, 2),
});
backupEntries.push({ name: 'crowdlending.db', data: fs.readFileSync(tmpDb) });
for (const subdir of ['logos', 'icons']) {
// 'documents' inclus au même titre que logos/icons : c'est un filet de
// sécurité, on sauvegarde tout ce qui est actuellement sur disque avant
// de l'écraser à l'étape 2, indépendamment de ce que référence la base.
for (const subdir of ['logos', 'icons', 'documents']) {
const dir = path.join(dataDir, subdir);
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir)) {
@@ -657,8 +754,16 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
fs.writeFileSync(path.join(exportsDir, `pre-restore-backup-${ts}.zip`), createZip(backupEntries));
purgeOldExports();
// 2. Remplacement des assets (logos + icons) — safe à faire en live
for (const subdir of ['logos', 'icons']) {
// 2. Remplacement des assets (logos + icons + documents) — safe à faire en live.
// 'documents' suit exactement le même traitement que logos/icons (demande
// Olivier 29/08/26) : le dossier est vidé puis repeuplé depuis le zip.
// Même remarque que pour logos/icons : entre ce remplacement et le
// redémarrage effectif du process qui applique le pending-restore de la
// base (étape 3/4), la base encore active référence des `filename` qui
// viennent d'être remplacés par ceux de l'export importé — fenêtre de
// cohérence transitoire déjà acceptée pour logos/icons, désormais partagée
// par documents.
for (const subdir of ['logos', 'icons', 'documents']) {
const dir = path.join(dataDir, subdir);
fs.mkdirSync(dir, { recursive: true });
// Vidage du dossier existant (fichiers et sous-dossiers)
@@ -697,11 +802,14 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
// 4. Réponse puis redémarrage
res.json({ ok: true, backup: `pre-restore-backup-${ts}.zip` });
setTimeout(() => {
if (!process.env.DATA_DIR) {
// Dev local : node --watch / nodemon n'auto-restart pas après un exit
// propre (process.exit(0)) — ils attendent un changement de fichier.
// On relance donc nous-mêmes un process enfant détaché avant de quitter,
// qui appliquera le .pending-restore à son démarrage (voir db/index.js).
if (process.env.NODE_ENV !== 'production') {
// Dev local (ou tout NODE_ENV non explicitement "production") : node --watch /
// nodemon n'auto-restart pas après un exit propre (process.exit(0)) — ils
// attendent un changement de fichier. On relance donc nous-mêmes un process
// enfant détaché avant de quitter, qui appliquera le .pending-restore à son
// démarrage (voir db/index.js). Anciennement conditionné à `!process.env.DATA_DIR`,
// qui ne détectait pas fiablement l'environnement (cf. commentaire de la route
// ci-dessus) — DATA_DIR est défini en dev local aussi.
try {
const child = spawn(process.execPath, [process.argv[1]], {
cwd: process.cwd(),
@@ -724,4 +832,102 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
}
});
/* ── Comptes orphelins (banque texte libre non reliée au référentiel institutions) ───────
Phase 2 du chantier "Référentiel Institutions financières" (cf. plan de déploiement, doc
projet claude/plan_referentiel_institutions_financieres.md). Contrairement aux plateformes
orphelines (une ligne par plateforme), ici on regroupe par valeur `banque` distincte car
plusieurs comptes (potentiellement de plusieurs utilisateurs) partagent souvent le même
texte libre — Lier/Importer agit donc sur tous les comptes portant cette valeur d'un coup. */
/** Liste les valeurs `banque` distinctes non reliées à institution_id, avec suggestion */
router.get('/comptes-orphelins', (_req, res) => {
// Groupé par (banque, user_id, investisseur_id) : `user_id` est le compte de connexion
// (potentiellement partagé par toute une famille — cf. `investisseurs`), `investisseur_id`
// le détenteur réel du compte bancaire (Olivier, Marine, Clément...). Sans ce second niveau,
// tous les comptes d'une même famille apparaissent à tort sous le seul nom du titulaire du
// compte de connexion (constaté par Olivier le 26/09/26).
const rows = db.prepare(`
SELECT c.banque, u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name,
c.investisseur_id, i.nom AS investisseur_nom,
COUNT(*) AS nb
FROM comptes c
JOIN users u ON u.id = c.user_id
LEFT JOIN investisseurs i ON i.id = c.investisseur_id
WHERE c.institution_id IS NULL AND c.banque IS NOT NULL AND TRIM(c.banque) != ''
GROUP BY c.banque, u.id, c.investisseur_id
ORDER BY c.banque, i.nom, u.email
`).all();
const byBanque = {};
for (const r of rows) {
if (!byBanque[r.banque]) byBanque[r.banque] = { banque: r.banque, total: 0, users: [] };
byBanque[r.banque].total += r.nb;
byBanque[r.banque].users.push({
id: r.user_id, email: r.user_email, display_name: r.user_display_name,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
nb: r.nb,
});
}
const refs = db.prepare('SELECT id, nom FROM institutions_referentiel ORDER BY nom').all();
const enriched = Object.values(byBanque).map(g => {
let bestRef = null, bestScore = 0;
for (const ref of refs) {
const score = similarity(g.banque, ref.nom);
if (score > bestScore) { bestScore = score; bestRef = ref; }
}
return {
...g,
suggestion: bestScore >= SIMILARITY_THRESHOLD
? { institution_id: bestRef.id, institution_nom: bestRef.nom, score: Math.round(bestScore * 100) }
: null,
};
}).sort((a, b) => a.banque.localeCompare(b.banque, 'fr'));
res.json(enriched);
});
/** Lie tous les comptes portant cette valeur `banque` à une institution existante du référentiel */
router.post('/comptes-orphelins/lier', (req, res, next) => {
try {
const { banque, institution_id } = z.object({
banque: z.string().min(1),
institution_id: z.number().int().positive(),
}).parse(req.body);
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institution_id);
if (!ref) throw new HttpError(404, 'Institution introuvable');
const r = db.prepare(
'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL'
).run(institution_id, banque);
res.json({ linked: true, nb_comptes: r.changes, institution: ref });
} catch (e) { next(e); }
});
/** Crée une nouvelle institution dans le référentiel à partir de la valeur `banque`, et lie
tous les comptes correspondants (ou lie simplement si une institution de ce nom existe déjà) */
router.post('/comptes-orphelins/importer', (req, res, next) => {
try {
const { banque } = z.object({ banque: z.string().min(1) }).parse(req.body);
let ref = db.prepare('SELECT * FROM institutions_referentiel WHERE nom = ?').get(banque);
let created = false;
if (!ref) {
const r = db.prepare('INSERT INTO institutions_referentiel (nom) VALUES (?)').run(banque);
ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(r.lastInsertRowid);
created = true;
}
const upd = db.prepare(
'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL'
).run(ref.id, banque);
res.status(created ? 201 : 200).json({ linked: true, created, nb_comptes: upd.changes, institution: ref });
} catch (e) { next(e); }
});
export default router;
+403
View File
@@ -0,0 +1,403 @@
/**
* /api/admin/ai — Configuration de l'Assistant IA embarqué (back-office).
* Protégé par requireAuth + requireAdmin (appliqués dans server.js).
*
* GET /providers — liste des fournisseurs (sans la clé API en clair)
* POST /providers — crée un fournisseur (clé API obligatoire)
* PUT /providers/:id — modifie un fournisseur (clé API optionnelle —
* voir le pattern « write-only » de smtp.js :
* omise = on conserve la clé déjà enregistrée)
* DELETE /providers/:id — supprime un fournisseur
* POST /providers/:id/test — vérifie la connexion (clé/modèle/base_url)
* avec un appel minimal, sans passer par le
* chat ni par le pont MCP — demandé par
* Olivier pour valider une config sans
* avoir à ouvrir le tchat utilisateur
* GET /config — configuration globale (actif, quota)
* PATCH /config — modifie la configuration globale
*
* Distinct de routes/ai.js (chat utilisateur, protégé par requireAiAccess) :
* ce routeur ne fait que gérer la configuration ; seul /providers/:id/test
* appelle réellement le fournisseur IA, et seulement pour un ping minimal.
*/
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { encryptSecret, decryptSecret } from '../ai/crypto.js';
import { audit } from '../utils/audit.js';
import { testConnection as testAnthropic } from '../ai/providers/anthropic.js';
import { testConnection as testOpenAiCompatible } from '../ai/providers/openaiCompatible.js';
const router = Router();
// ── Helpers ──────────────────────────────────────────────────────────────
function serializeProvider(row) {
return {
id: row.id,
nom: row.nom,
type: row.type,
baseUrl: row.base_url || '',
modele: row.modele,
hasApiKey: !!row.api_key_chiffree,
actif: !!row.actif,
isDefault: !!row.is_default,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function ensureConfigRow() {
db.prepare('INSERT OR IGNORE INTO ai_config (id, actif) VALUES (1, 0)').run();
}
// ── GET /providers ───────────────────────────────────────────────────────
router.get('/providers', (_req, res) => {
const rows = db.prepare('SELECT * FROM ai_providers ORDER BY is_default DESC, id ASC').all();
res.json(rows.map(serializeProvider));
});
// ── POST /providers ──────────────────────────────────────────────────────
const CreateProviderSchema = z.object({
nom: z.string().min(1).max(100),
type: z.enum(['anthropic', 'openai_compatible']),
baseUrl: z.string().max(500).optional(),
modele: z.string().min(1).max(200),
apiKey: z.string().min(1),
actif: z.boolean().optional(),
isDefault: z.boolean().optional(),
});
router.post('/providers', (req, res, next) => {
try {
const body = CreateProviderSchema.parse(req.body);
if (body.type === 'openai_compatible' && !body.baseUrl?.trim()) {
throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1).");
}
const apiKeyChiffree = encryptSecret(body.apiKey);
const tx = db.transaction(() => {
if (body.isDefault) {
db.prepare('UPDATE ai_providers SET is_default = 0').run();
}
const r = db.prepare(`
INSERT INTO ai_providers (nom, type, base_url, modele, api_key_chiffree, actif, is_default, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
`).run(
body.nom.trim(),
body.type,
body.baseUrl?.trim() || null,
body.modele.trim(),
apiKeyChiffree,
body.actif === false ? 0 : 1,
body.isDefault ? 1 : 0,
);
return r.lastInsertRowid;
});
const id = tx();
audit(req, { action: 'ai_provider_created', category: 'ai', actorId: req.user.id, details: { id, nom: body.nom, type: body.type } });
res.status(201).json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id)));
} catch (e) { next(e); }
});
// ── PUT /providers/:id ───────────────────────────────────────────────────
const UpdateProviderSchema = z.object({
nom: z.string().min(1).max(100).optional(),
type: z.enum(['anthropic', 'openai_compatible']).optional(),
baseUrl: z.string().max(500).optional(),
modele: z.string().min(1).max(200).optional(),
apiKey: z.string().min(1).optional(), // omis = clé conservée telle quelle
actif: z.boolean().optional(),
isDefault: z.boolean().optional(),
});
router.put('/providers/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
if (!row) throw new HttpError(404, 'Fournisseur introuvable');
const body = UpdateProviderSchema.parse(req.body);
const type = body.type !== undefined ? body.type : row.type;
const baseUrl = body.baseUrl !== undefined ? body.baseUrl.trim() : row.base_url;
if (type === 'openai_compatible' && !baseUrl) {
throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1).");
}
const apiKeyChiffree = body.apiKey !== undefined ? encryptSecret(body.apiKey) : row.api_key_chiffree;
db.transaction(() => {
if (body.isDefault) {
db.prepare('UPDATE ai_providers SET is_default = 0 WHERE id != ?').run(id);
}
db.prepare(`
UPDATE ai_providers SET
nom = ?, type = ?, base_url = ?, modele = ?, api_key_chiffree = ?,
actif = ?, is_default = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
body.nom !== undefined ? body.nom.trim() : row.nom,
type,
baseUrl || null,
body.modele !== undefined ? body.modele.trim() : row.modele,
apiKeyChiffree,
body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif,
body.isDefault !== undefined ? (body.isDefault ? 1 : 0) : row.is_default,
id,
);
})();
audit(req, { action: 'ai_provider_updated', category: 'ai', actorId: req.user.id, details: { id, apiKeyChanged: body.apiKey !== undefined } });
res.json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id)));
} catch (e) { next(e); }
});
// ── DELETE /providers/:id ────────────────────────────────────────────────
router.delete('/providers/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT id, nom FROM ai_providers WHERE id = ?').get(id);
if (!row) throw new HttpError(404, 'Fournisseur introuvable');
db.transaction(() => {
// provider_defaut_id référence ai_providers sans PRAGMA foreign_keys=ON
// garanti actif dans ce process : on nettoie explicitement plutôt que
// de compter sur ON DELETE SET NULL.
db.prepare('UPDATE ai_config SET provider_defaut_id = NULL WHERE provider_defaut_id = ?').run(id);
db.prepare('UPDATE ai_usage_log SET provider_id = NULL WHERE provider_id = ?').run(id);
db.prepare('DELETE FROM ai_providers WHERE id = ?').run(id);
})();
audit(req, { action: 'ai_provider_deleted', category: 'ai', actorId: req.user.id, details: { id, nom: row.nom } });
res.json({ deleted: true });
} catch (e) { next(e); }
});
// ── POST /providers/:id/test ─────────────────────────────────────────────
// Réponse toujours 200 : { ok:true, reponse, dureeMs } en cas de succès,
// { ok:false, error } si le fournisseur a répondu une erreur ou n'a pas pu
// être joint — seule une ligne introuvable ou une clé indéchiffrable est
// une vraie erreur HTTP (le test lui-même a bien pu s'exécuter).
router.post('/providers/:id/test', async (req, res, next) => {
try {
const id = Number(req.params.id);
const provider = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
if (!provider) throw new HttpError(404, 'Fournisseur introuvable');
if (!provider.api_key_chiffree) {
return res.json({ ok: false, error: "Aucune clé API enregistrée pour ce fournisseur." });
}
let apiKey;
try {
apiKey = decryptSecret(provider.api_key_chiffree);
} catch (e) {
throw new HttpError(500, `Impossible de déchiffrer la clé API : ${e.message}`);
}
if (provider.type === 'openai_compatible' && !provider.base_url) {
return res.json({ ok: false, error: "URL de base manquante pour ce fournisseur." });
}
try {
const result = provider.type === 'anthropic'
? await testAnthropic({ apiKey, model: provider.modele })
: await testOpenAiCompatible({ apiKey, baseUrl: provider.base_url, model: provider.modele });
audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: true, dureeMs: result.durationMs } });
res.json({ ok: true, reponse: result.reply, dureeMs: result.durationMs });
} catch (e) {
audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: false, error: e.message } });
res.json({ ok: false, error: e.message });
}
} catch (e) { next(e); }
});
// ── GET /config ───────────────────────────────────────────────────────────
router.get('/config', (_req, res) => {
ensureConfigRow();
const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get();
res.json({
actif: !!row.actif,
quotaQuotidien: row.quota_quotidien ?? null,
updatedAt: row.updated_at,
});
});
// ── PATCH /config ─────────────────────────────────────────────────────────
const PatchConfigSchema = z.object({
actif: z.boolean().optional(),
quotaQuotidien: z.number().int().positive().nullable().optional(),
});
router.patch('/config', (req, res, next) => {
try {
ensureConfigRow();
const body = PatchConfigSchema.parse(req.body);
const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get();
if (body.actif === true) {
const activeProvider = db.prepare("SELECT id FROM ai_providers WHERE actif = 1 LIMIT 1").get();
if (!activeProvider) {
throw new HttpError(400, "Impossible d'activer l'Assistant IA : aucun fournisseur actif n'est configuré.");
}
}
db.prepare(`
UPDATE ai_config SET actif = ?, quota_quotidien = ?, updated_at = datetime('now') WHERE id = 1
`).run(
body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif,
body.quotaQuotidien !== undefined ? body.quotaQuotidien : row.quota_quotidien,
);
// Desactivation globale (transition actif -> inactif) : on retire aussi
// l'acces individuel de chaque utilisateur plutot que de le laisser
// "vrai" en base mais inoperant, ce qui afficherait un interrupteur
// actif mais sans effet dans le panneau Utilisateurs.
let usersReset = 0;
if (body.actif === false && row.actif) {
usersReset = db.prepare('UPDATE users SET ai_enabled = 0 WHERE ai_enabled = 1').run().changes;
}
audit(req, { action: 'ai_config_updated', category: 'ai', actorId: req.user.id, details: { ...body, usersReset } });
res.json({ ok: true, usersReset });
} catch (e) { next(e); }
});
// ── GET /usage/summary ───────────────────────────────────────────────────
// Vue d'ensemble de la consommation : total requetes/tokens sur la periode,
// repartition par utilisateur, et serie quotidienne (pour un mini-graphe).
// `days` : fenetre glissante en jours (defaut 30, borne a [1, 365]).
router.get('/usage/summary', (req, res) => {
const days = Math.min(Math.max(Number(req.query.days) || 30, 1), 365);
const since = `-${days} days`;
const totals = db.prepare(`
SELECT
COUNT(*) AS requests,
COALESCE(SUM(tokens_entree), 0) AS tokensIn,
COALESCE(SUM(tokens_sortie), 0) AS tokensOut,
SUM(CASE WHEN erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors
FROM ai_usage_log
WHERE created_at >= datetime('now', ?)
`).get(since);
const byUser = db.prepare(`
SELECT
u.id AS userId, u.email, u.display_name AS displayName,
COUNT(*) AS requests,
COALESCE(SUM(l.tokens_entree), 0) AS tokensIn,
COALESCE(SUM(l.tokens_sortie), 0) AS tokensOut,
SUM(CASE WHEN l.erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors,
MAX(l.created_at) AS lastUsed
FROM ai_usage_log l
JOIN users u ON u.id = l.user_id
WHERE l.created_at >= datetime('now', ?)
GROUP BY u.id
ORDER BY requests DESC
`).all(since);
const daily = db.prepare(`
SELECT
date(created_at) AS day,
COUNT(*) AS requests,
COALESCE(SUM(tokens_entree), 0) AS tokensIn,
COALESCE(SUM(tokens_sortie), 0) AS tokensOut
FROM ai_usage_log
WHERE created_at >= datetime('now', ?)
GROUP BY date(created_at)
ORDER BY day ASC
`).all(since);
res.json({
days,
totals: {
requests: totals.requests,
tokensIn: totals.tokensIn,
tokensOut: totals.tokensOut,
tokensTotal: totals.tokensIn + totals.tokensOut,
errors: totals.errors,
},
byUser: byUser.map(r => ({
userId: r.userId,
email: r.email,
displayName: r.displayName,
requests: r.requests,
tokensIn: r.tokensIn,
tokensOut: r.tokensOut,
tokensTotal: r.tokensIn + r.tokensOut,
errors: r.errors,
lastUsed: r.lastUsed,
})),
daily,
});
});
// ── GET /usage/log ───────────────────────────────────────────────────────
// Journal detaille, pagine, requete par requete — pour l'audit fin.
// Filtrable par utilisateur (userId) et par fenetre (days).
router.get('/usage/log', (req, res) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const userId = req.query.userId ? Number(req.query.userId) : null;
const days = req.query.days ? Math.min(Math.max(Number(req.query.days), 1), 365) : null;
const conditions = [];
const params = [];
if (userId) { conditions.push('l.user_id = ?'); params.push(userId); }
if (days) { conditions.push("l.created_at >= datetime('now', ?)"); params.push(`-${days} days`); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const total = db.prepare(`SELECT COUNT(*) AS n FROM ai_usage_log l ${where}`).get(...params).n;
const rows = db.prepare(`
SELECT
l.id, l.created_at AS createdAt, l.modele,
l.tokens_entree AS tokensIn, l.tokens_sortie AS tokensOut,
l.outils_appeles AS outilsAppeles, l.erreur,
u.id AS userId, u.email, u.display_name AS displayName,
i.nom AS investisseurNom,
p.nom AS providerNom
FROM ai_usage_log l
JOIN users u ON u.id = l.user_id
LEFT JOIN investisseurs i ON i.id = l.investisseur_id
LEFT JOIN ai_providers p ON p.id = l.provider_id
${where}
ORDER BY l.id DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
res.json({
total,
rows: rows.map(r => ({
id: r.id,
createdAt: r.createdAt,
modele: r.modele,
tokensIn: r.tokensIn,
tokensOut: r.tokensOut,
tokensTotal: (r.tokensIn || 0) + (r.tokensOut || 0),
outilsAppeles: r.outilsAppeles ? JSON.parse(r.outilsAppeles) : [],
erreur: r.erreur,
userId: r.userId,
email: r.email,
displayName: r.displayName,
investisseurNom: r.investisseurNom,
providerNom: r.providerNom,
})),
});
});
export default router;
+120
View File
@@ -0,0 +1,120 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { audit } from '../utils/audit.js';
/* ── Catalogue des workspaces (Administration > Espaces de travail) ──────
* Gère la liste globale des espaces de travail proposés par l'application
* (ex. "Crowdlending", futur "Private Equity"…) — indépendant de l'octroi
* par utilisateur, qui se fait via PATCH /admin/users/:id/workspaces (voir
* admin.js). Voir project_workspaces_transformation.md (mémoire projet)
* pour le plan complet. requireAuth + requireAdmin appliqués dans server.js
* avant ce router. */
const router = Router();
// Types de workspace reconnus par l'app (19/09/26 — cf. db/index.js pour le contexte complet
// de cette migration). 'crowdlending' est le singleton historique (un seul exemplaire, imposé
// par l'index unique partiel côté DB et re-vérifié ici pour un message d'erreur clair) ;
// 'private_equity' peut avoir plusieurs exemplaires (ex. si Olivier veut un jour séparer deux
// portefeuilles PE distincts). 'patrimoine' (28/09/26, chantier Workspace Patrimoine — cf.
// claude/plan_workspace_patrimoine.md) est un 2e singleton (dashboard consolidé unique, décision
// Olivier) — même régime que crowdlending, imposé par idx_workspaces_type_patrimoine_singleton
// côté DB et re-vérifié ici. Un 4e type nécessiterait d'abord une page/route dédiée avant
// d'avoir un sens ici — l'ajouter à cette liste seul ne suffit pas.
const WORKSPACE_TYPES = ['crowdlending', 'private_equity', 'patrimoine'];
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT w.*,
(SELECT COUNT(*) FROM user_workspaces uw WHERE uw.workspace_id = w.id AND uw.granted_by_admin = 1) AS nb_utilisateurs
FROM workspaces w
ORDER BY w.ordre ASC, w.id ASC
`).all();
res.json(rows);
});
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const WorkspaceCreateSchema = z.object({
slug: z.string().min(1).max(40).regex(SLUG_RE, 'Slug : minuscules, chiffres et tirets uniquement'),
nom: z.string().min(1),
libelleMenu: z.string().min(1),
description: z.string().optional(),
// Choisi une fois à la création, jamais modifiable ensuite (même régime que slug, cf.
// WorkspaceUpdateSchema ci-dessous qui n'inclut ni l'un ni l'autre) — changer le type d'un
// workspace déjà peuplé de données changerait silencieusement le modèle de page/API qui
// s'applique à ces données.
type: z.enum(WORKSPACE_TYPES, { errorMap: () => ({ message: `Type invalide (valeurs acceptées : ${WORKSPACE_TYPES.join(', ')})` }) }),
actifGlobal: z.boolean().default(true),
ordre: z.number().int().default(0),
});
router.post('/', (req, res, next) => {
try {
const body = WorkspaceCreateSchema.parse(req.body);
const exists = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(body.slug);
if (exists) throw new HttpError(409, 'Ce slug est déjà utilisé');
// Cf. l'index unique partiel côté DB (idx_workspaces_type_crowdlending_singleton /
// idx_workspaces_type_patrimoine_singleton) : cette vérification applicative donne un
// message clair avant même d'atteindre la contrainte SQL.
if (body.type === 'crowdlending') {
const alreadyCl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (alreadyCl) throw new HttpError(409, 'Un workspace de type Crowdlending existe déjà');
}
if (body.type === 'patrimoine') {
const alreadyPat = db.prepare("SELECT id FROM workspaces WHERE type = 'patrimoine'").get();
if (alreadyPat) throw new HttpError(409, 'Un workspace de type Patrimoine existe déjà');
}
const r = db.prepare(`
INSERT INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre)
VALUES (?,?,?,?,?,?,?)
`).run(body.slug, body.nom, body.libelleMenu, body.description || null, body.type, body.actifGlobal ? 1 : 0, body.ordre);
audit(req, { action: 'workspace_created', category: 'workspaces', actorId: req.user.id, details: { slug: body.slug, type: body.type } });
res.status(201).json({ id: r.lastInsertRowid, slug: body.slug, nom: body.nom, libelle_menu: body.libelleMenu, type: body.type });
} catch (e) { next(e); }
});
const WorkspaceUpdateSchema = z.object({
nom: z.string().min(1),
libelleMenu: z.string().min(1),
description: z.string().optional(),
actifGlobal: z.boolean(),
ordre: z.number().int(),
});
router.put('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare('SELECT id FROM workspaces WHERE id = ?').get(id);
if (!existing) throw new HttpError(404, 'Espace de travail introuvable');
const body = WorkspaceUpdateSchema.parse(req.body);
db.prepare(`
UPDATE workspaces SET nom=?, libelle_menu=?, description=?, actif_global=?, ordre=?, updated_at=datetime('now')
WHERE id=?
`).run(body.nom, body.libelleMenu, body.description || null, body.actifGlobal ? 1 : 0, body.ordre, id);
audit(req, { action: 'workspace_updated', category: 'workspaces', actorId: req.user.id, details: { id } });
res.json({ ok: true });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
if (!ws) throw new HttpError(404, 'Espace de travail introuvable');
// Le workspace socle ne peut pas être supprimé — toute l'app actuelle en dépend.
if (ws.type === 'crowdlending') throw new HttpError(400, 'Le workspace Crowdlending ne peut pas être supprimé');
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
audit(req, { action: 'workspace_deleted', category: 'workspaces', actorId: req.user.id, details: { slug: ws.slug } });
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+269
View File
@@ -0,0 +1,269 @@
/**
* routes/ai.js — Assistant IA embarqué.
*
* GET /api/ai/status — l'utilisateur courant a-t-il accès au chat ?
* GET /api/ai/conversations — liste des conversations (les plus récentes en premier)
* GET /api/ai/conversations/:id/messages — historique d'une conversation
* DELETE /api/ai/conversations/:id — supprime une conversation (et ses messages)
* POST /api/ai/chat — envoie un message et reçoit la réponse en streaming (SSE)
*
* Deux adaptateurs fournisseur, sélectionnés selon provider.type :
* "anthropic" (ai/providers/anthropic.js, SDK officiel) et
* "openai_compatible" (ai/providers/openaiCompatible.js, fetch + SSE manuel
* — OpenAI, Mistral, Groq, un LLM local, ou OpenRouter via provider.base_url).
*
* Conversations : plusieurs fils nommés par couple user/investisseur (voir
* ai/conversationHistory.js), partagés entre le widget flottant et la page
* dédiée /assistant-ia via leur id. POST /chat accepte un conversationId
* optionnel dans le corps de la requête — omis (ou introuvable), une
* nouvelle conversation est créée à la volée, titrée à partir du message.
* Le premier événement SSE envoyé est toujours `conversation` ({id, titre})
* pour que le client apprenne l'id (nouveau ou confirmé) avant tout texte.
*
* Historique : le client n'envoie que le dernier message utilisateur (body :
* { conversationId, message }) — l'historique complet est reconstitué
* côté serveur depuis ai_messages puis complété par ce nouveau message
* avant l'appel au modèle. Les deux messages (utilisateur puis assistant)
* sont persistés : l'utilisateur dès réception (pour ne pas le perdre même
* si le modèle échoue ensuite), l'assistant seulement si le tour se
* termine par un événement 'done' (une réponse interrompue par une erreur
* n'est pas enregistrée en tant que telle — l'utilisateur peut simplement
* reposer sa question).
*/
import { Router } from 'express';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { requireAiAccess, getAiAccessStatus, checkAiQuota } from '../middleware/aiAccess.js';
import { getDefaultActiveProvider } from '../ai/providerConfig.js';
import { decryptSecret } from '../ai/crypto.js';
import { createEphemeralInternalApiKey, revokeInternalApiKey } from '../ai/internalApiKey.js';
import { createAiToolsClient } from '../ai/mcpBridge.js';
import {
listConversations, getConversation, createConversation, deleteConversation,
getHistory, appendMessage,
} from '../ai/conversationHistory.js';
import { streamChat as streamChatAnthropic } from '../ai/providers/anthropic.js';
import { streamChat as streamChatOpenAiCompatible } from '../ai/providers/openaiCompatible.js';
const router = Router();
/** GET /api/ai/status — volontairement SANS requireAiAccess NI
* requireInvestisseur : doit répondre même pour un utilisateur non
* autorisé, ou dont le scope investisseur n'est pas encore résolu côté
* client (course de démarrage de page), pour que le frontend sache
* masquer le point d'entrée du chat plutôt que de tenter un appel
* refusé ou d'attendre indéfiniment une résolution qui n'arrivera
* jamais pour cette requête. */
router.get('/status', (req, res) => {
res.json({ enabled: getAiAccessStatus(req.user.id).enabled });
});
router.use(requireInvestisseur);
const SELF_API_BASE = `http://localhost:${process.env.PORT || 4000}/api/v1`;
/** Même logique que makeApiGet() dans mcp-server/server.js (clé API en
* en-tête X-API-Key vers l'API v1) — dupliquée ici volontairement plutôt
* que partagée entre les deux process (backend et mcp-server tournent
* séparément) ; toute divergence de comportement entre les deux serait un
* bug à corriger dans les deux endroits. */
function makeApiGet(apiKey) {
return async function apiGet(path, params) {
const url = new URL(SELF_API_BASE + path);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
}
}
let res;
try {
res = await fetch(url, {
headers: { 'X-API-Key': apiKey, Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
});
} catch (e) {
throw new Error(`Impossible de joindre l'API interne : ${e.message}`);
}
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
if (!res.ok) {
const msg = (body && body.error) || res.statusText || 'Requête échouée';
throw new Error(`Erreur API (${res.status}) : ${msg}`);
}
return body;
};
}
const SYSTEM_PROMPT =
"Tu es l'assistant IA embarqué de l'application Crowdlending Tracker. " +
"Tu réponds aux questions de l'utilisateur sur son portefeuille de " +
"crowdlending en t'appuyant exclusivement sur les outils mis à ta " +
"disposition (lecture seule — tu ne peux rien modifier dans l'application). " +
"Sois précis, cite les montants exacts renvoyés par les outils, et indique " +
"clairement quand une information n'est pas disponible plutôt que de " +
"l'inventer. Les montants sont en euros sauf indication contraire.";
/** Dérive un titre de conversation à partir du premier message (aplati sur
* une ligne, tronqué). Jamais renommé automatiquement ensuite. */
function titleFromMessage(content) {
const flat = content.replace(/\s+/g, ' ').trim();
if (!flat) return 'Nouvelle conversation';
return flat.length > 60 ? `${flat.slice(0, 57)}…` : flat;
}
/** GET /api/ai/conversations — liste des conversations du fil user+investisseur
* courant, les plus récemment actives en premier (widget + page dédiée). */
router.get('/conversations', requireAiAccess, (req, res) => {
res.json({ conversations: listConversations(req.user.id, req.investisseur.id) });
});
/** GET /api/ai/conversations/:id/messages — historique d'une conversation
* précise, pour que le widget ou la page dédiée puissent la recharger
* (après un refresh de page, ou en la sélectionnant dans la liste). */
router.get('/conversations/:id/messages', requireAiAccess, (req, res) => {
const id = Number(req.params.id);
const conversation = getConversation(req.user.id, req.investisseur.id, id);
if (!conversation) throw new HttpError(404, 'Conversation introuvable.');
res.json({ conversation, messages: getHistory(id, 200) });
});
/** DELETE /api/ai/conversations/:id — supprime définitivement une
* conversation et ses messages. */
router.delete('/conversations/:id', requireAiAccess, (req, res) => {
const id = Number(req.params.id);
const ok = deleteConversation(req.user.id, req.investisseur.id, id);
if (!ok) throw new HttpError(404, 'Conversation introuvable.');
res.json({ deleted: true });
});
/** POST /api/ai/chat — SSE. Body : { conversationId?: number, message: string }.
* conversationId omis (ou introuvable) → nouvelle conversation créée à la volée. */
router.post('/chat', requireAiAccess, async (req, res) => {
const userContent = typeof req.body?.message === 'string' ? req.body.message.trim() : '';
if (!userContent) {
return res.status(400).json({ error: 'message requis (chaîne non vide)' });
}
const quota = checkAiQuota(req.user.id);
if (!quota.allowed) {
return res.status(429).json({ error: `Quota quotidien atteint (${quota.limit} requêtes/jour).` });
}
const provider = getDefaultActiveProvider();
if (!provider) {
return res.status(503).json({ error: "Aucun fournisseur IA actif n'est configuré." });
}
if (provider.type !== 'anthropic' && provider.type !== 'openai_compatible') {
return res.status(501).json({ error: `Adaptateur "${provider.type}" non supporté.` });
}
if (provider.type === 'openai_compatible' && !provider.base_url) {
return res.status(500).json({ error: "Fournisseur openai_compatible mal configuré : URL de base manquante." });
}
let apiKey;
try {
apiKey = decryptSecret(provider.api_key_chiffree);
} catch (e) {
return res.status(500).json({ error: `Impossible de déchiffrer la clé du fournisseur : ${e.message}` });
}
// Résout la conversation : existante et possédée par l'utilisateur, ou
// nouvelle (créée seulement maintenant qu'on sait que la requête est
// valide — pas de conversation vide créée sur un échec de quota/config).
const requestedId = req.body?.conversationId != null ? Number(req.body.conversationId) : null;
let conversation = requestedId ? getConversation(req.user.id, req.investisseur.id, requestedId) : null;
if (requestedId && !conversation) {
return res.status(404).json({ error: 'Conversation introuvable.' });
}
if (!conversation) {
conversation = createConversation(req.user.id, req.investisseur.id, titleFromMessage(userContent));
}
// Historique persisté + nouveau message, pour l'appel au modèle.
const priorHistory = getHistory(conversation.id, 40);
const messages = [...priorHistory.map((m) => ({ role: m.role, content: m.content })), { role: 'user', content: userContent }];
appendMessage(conversation.id, req.user.id, req.investisseur.id, 'user', userContent);
const internalKey = createEphemeralInternalApiKey(req.user.id, req.investisseur.id);
let mcpClient = null;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const send = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
// Toujours le premier événement : le client apprend l'id de conversation
// (nouveau ou confirmé) avant tout texte, pour synchroniser son URL/liste.
send('conversation', { type: 'conversation', id: conversation.id, titre: conversation.titre });
let closed = false;
// res.on('close'), pas req.on('close') : req ('close') peut se declencher des
// que le corps de la requete POST a fini d'etre lu (faux positif frequent avec
// Express/Node), bien avant toute vraie deconnexion du client. res ('close')
// reflete l'etat reel de la connexion de reponse (SSE en cours).
res.on('close', () => { closed = true; });
let assistantContent = '';
const toolsUsedLog = [];
let usageLog = null;
let errorLog = null;
let completed = false;
try {
mcpClient = await createAiToolsClient(makeApiGet(internalKey.key));
const streamFn = provider.type === 'anthropic' ? streamChatAnthropic : streamChatOpenAiCompatible;
const streamOpts = provider.type === 'anthropic'
? { apiKey, model: provider.modele, systemPrompt: SYSTEM_PROMPT, messages, mcpClient }
: { apiKey, baseUrl: provider.base_url, model: provider.modele, systemPrompt: SYSTEM_PROMPT, messages, mcpClient };
for await (const event of streamFn(streamOpts)) {
if (closed) break;
if (event.type === 'text_delta') assistantContent += event.text;
if (event.type === 'tool_call') toolsUsedLog.push(event.name);
if (event.type === 'done') { usageLog = event.usage; completed = true; }
if (event.type === 'error') errorLog = event.message;
send(event.type, event);
}
} catch (e) {
errorLog = e.message;
if (!closed) send('error', { type: 'error', message: e.message });
} finally {
revokeInternalApiKey(internalKey.id);
if (mcpClient) {
try { await mcpClient.close(); } catch { /* best-effort */ }
}
if (completed && assistantContent) {
appendMessage(conversation.id, req.user.id, req.investisseur.id, 'assistant', assistantContent, toolsUsedLog);
}
try {
db.prepare(`
INSERT INTO ai_usage_log
(user_id, investisseur_id, provider_id, modele, tokens_entree, tokens_sortie, outils_appeles, erreur)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user.id,
req.investisseur.id,
provider.id,
provider.modele,
usageLog?.input_tokens ?? null,
usageLog?.output_tokens ?? null,
toolsUsedLog.length ? JSON.stringify(toolsUsedLog) : null,
errorLog,
);
} catch (e) {
console.error("[ai/chat] échec du log d'usage (ignoré) :", e.message);
}
if (!closed) res.end();
}
});
export default router;
+2 -2
View File
@@ -7,7 +7,7 @@ const router = Router();
const KEY_PREFIX_LEN = 12; // ex: "clk_live_ab3" — assez pour identifier sans exposer le secret
function generateKey() {
export function generateKey() {
const secret = crypto.randomBytes(24).toString('hex'); // 48 caractères hex
const full = `clk_live_${secret}`;
const hash = crypto.createHash('sha256').update(full).digest('hex');
@@ -21,7 +21,7 @@ router.get('/', (req, res) => {
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k
JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.user_id = ?
WHERE k.user_id = ? AND k.internal = 0
ORDER BY k.revoked_at IS NOT NULL, k.created_at DESC
`).all(req.user.id);
res.json(rows);
+36 -5
View File
@@ -2,7 +2,7 @@ import { Router } from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import { z } from 'zod';
import db from '../db/index.js';
import db, { grantDefaultWorkspace } from '../db/index.js';
import { signToken, requireAuth } from '../middleware/auth.js';
import { HttpError } from '../middleware/errorHandler.js';
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
@@ -11,6 +11,7 @@ const _require = createRequire(import.meta.url);
const QRCode = _require('qrcode');
import { generateSecret as totpGenerateSecret, generateURI as totpGenerateURI, verifySync as totpVerifySync } from 'otplib';
import { audit } from '../utils/audit.js';
import { deleteAllDocumentFilesForUser } from './documents.js';
const router = Router();
@@ -54,10 +55,19 @@ router.post('/register', async (req, res, next) => {
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
).run(userId, fullName, prenom);
// Auto-créer un compte courant pour le profil principal
// Auto-créer un compte courant pour le profil principal — rattaché directement à
// l'enveloppe "Compte courant non rémunéré" et marqué compte de règlement par défaut
// (28/09/26 (suite), demande Olivier).
const enveloppeCompteCourantNonRemunere = db.prepare(
"SELECT id FROM enveloppes_referentiel WHERE nom = 'Compte courant non rémunéré'"
).get();
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
'INSERT INTO comptes (user_id, nom, type, enveloppe_id, investisseur_id, compte_reglement_par_defaut) VALUES (?,?,?,?,?,1)'
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', enveloppeCompteCourantNonRemunere?.id ?? null, invResult.lastInsertRowid);
// Accorde l'accès au workspace socle (crowdlending) à tout nouveau compte
// — cf. project_workspaces_transformation.md.
grantDefaultWorkspace(userId);
// Si l'utilisateur doit vérifier son email → envoyer l'email de bienvenue
if (!autoVerified) {
@@ -172,7 +182,22 @@ router.post('/login', async (req, res, next) => {
router.get('/me', requireAuth, (req, res) => {
const user = db
.prepare('SELECT id, email, display_name, role, email_verified, totp_enabled FROM users WHERE id = ?')
.prepare(`
SELECT id, email, display_name, role, email_verified, totp_enabled,
EXISTS(
SELECT 1 FROM frais_operations f
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,
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);
res.json({ user });
});
@@ -313,6 +338,12 @@ router.delete('/me', requireAuth, (req, res, next) => {
notifyTx(otherAdmins);
}
// Fichiers documents de l'utilisateur : à effacer AVANT le DELETE FROM users
// (les lignes `documents` seront supprimées en cascade par la contrainte
// ON DELETE CASCADE, mais une cascade SQLite n'efface jamais les fichiers
// du disque — il faut les lister pendant que le user existe encore).
deleteAllDocumentFilesForUser(user.id);
// Suppression définitive — cascade en base sur toutes les données liées
// (investisseurs, plateformes, investissements, remboursements, comptes,
// préférences, notifications, tickets, appareils de confiance, etc.)
+50 -11
View File
@@ -2,24 +2,51 @@ import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { recordValorisation } from '../utils/valorisationsPatrimoine.js';
const router = Router();
const Schema = z.object({
nom: z.string().min(1),
type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'),
investisseur_id: z.number().int().positive().nullable().optional(),
banque: z.string().nullable().optional(),
institution_id: z.number().int().positive().nullable().optional(),
// Phase 3a (28/09/26) — rattachement au référentiel enveloppes_referentiel, purement
// descriptif et non-destructif (cf. commentaire de migration dans db/index.js). Non contraint
// à eligible_compte=1 côté Zod — le filtrage se fait côté frontend.
enveloppe_id: z.number().int().positive().nullable().optional(),
exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'),
// Fusion Type/Enveloppe (28/09/26 (suite), demande Olivier) — remplace l'ancien champ `type`
// (compte_courant/pea_pme) comme unique source de vérité pour la sélection d'un compte de
// règlement par défaut sur les remboursements/frais (cf. migration compte_reglement_par_defaut
// dans db/index.js). Au plus un compte par investisseur ; l'exclusivité est appliquée
// ci-dessous via clearOtherDefaults(), pas via une contrainte SQL (investisseur_id nullable).
compte_reglement_par_defaut: z.boolean().optional().default(false),
// Chantier Workspace Patrimoine (28/09/26) — saisie manuelle, cf. migration solde_eur dans
// db/index.js. Nullable : un compte sans saisie reste "non renseigné" plutôt que 0€.
solde_eur: z.number().nullable().optional(),
});
function clearOtherDefaults(userId, investisseurId, excludeId) {
if (!investisseurId) return;
db.prepare(
'UPDATE comptes SET compte_reglement_par_defaut = 0 WHERE investisseur_id = ? AND user_id = ? AND id <> ?'
).run(investisseurId, userId, excludeId ?? -1);
}
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT c.id, c.nom, c.type, c.banque, c.exoneration_fiscale, c.investisseur_id, c.created_at,
SELECT c.id, c.nom, c.banque, c.institution_id, c.enveloppe_id, c.exoneration_fiscale,
c.investisseur_id, c.compte_reglement_par_defaut, c.solde_eur, c.created_at,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename,
er.nom AS enveloppe_nom
FROM comptes c
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
LEFT JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id
WHERE c.user_id = ?
ORDER BY c.nom
`).all(req.user.id);
@@ -29,20 +56,32 @@ router.get('/', (req, res) => {
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db.prepare(
'INSERT INTO comptes (user_id, nom, type, banque, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?)'
).run(req.user.id, body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
res.status(201).json({ id: r.lastInsertRowid, ...body });
const defaut = body.compte_reglement_par_defaut ? 1 : 0;
const id = db.transaction(() => {
if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id);
const r = db.prepare(
'INSERT INTO comptes (user_id, nom, banque, institution_id, enveloppe_id, investisseur_id, exoneration_fiscale, compte_reglement_par_defaut, solde_eur) VALUES (?,?,?,?,?,?,?,?,?)'
).run(req.user.id, body.nom, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', defaut, body.solde_eur ?? null);
recordValorisation({ compteId: r.lastInsertRowid, valeur: body.solde_eur });
return r.lastInsertRowid;
})();
res.status(201).json({ id, ...body });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db.prepare(
`UPDATE comptes SET nom=?, type=?, banque=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
const defaut = body.compte_reglement_par_defaut ? 1 : 0;
const changes = db.transaction(() => {
if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id, Number(req.params.id));
const result = db.prepare(
`UPDATE comptes SET nom=?, banque=?, institution_id=?, enveloppe_id=?, investisseur_id=?, exoneration_fiscale=?, compte_reglement_par_defaut=?, solde_eur=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.nom, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', defaut, body.solde_eur ?? null, req.params.id, req.user.id).changes;
if (result > 0) recordValorisation({ compteId: Number(req.params.id), valeur: body.solde_eur });
return result;
})();
if (changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
});
+79 -3
View File
@@ -125,6 +125,51 @@ 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
// (Fundora, 31/08/26) où la plateforme débite le montant BRUT, pas seulement le net investi.
// 'portefeuille' est un versement complémentaire distinct de montant_investi, jamais câblé
// jusqu'ici. 'compte_courant' reste exclu (aucun impact sur le porte-monnaie).
// Inclut désormais aussi les frais PE (investissement_pe_id) — jusqu'ici absents (LEFT JOIN
// ajouté le 19/09/26, bug trouvé par Claude en creusant le signalement Olivier du même jour
// sur le solde indicatif : une plateforme partagée crowdlending/PE voyait son solde_portefeuille
// légèrement surestimé dès qu'un frais PE "source"/"portefeuille" existait, ce frais n'étant
// jamais déduit nulle part). COALESCE sur investisseur_id : un frais n'a jamais les deux à la
// fois (CHECK en base), donc au plus un des deux LEFT JOIN matche réellement.
// Correctif du même jour (Olivier, après coup) : pour un deal PE, 'source' n'est PAS un débit
// supplémentaire — montant_investi (déjà déduit via capitalInvestiPePerPlat plus bas) est le
// montant BRUT transféré, frais compris. Ne compter un frais PE que s'il est 'portefeuille'
// (versement complémentaire, réellement distinct) ; côté crowdlending, 'source' reste compté
// (montant_investi y est net — cf. cas Fundora du 31/08/26, modèle différent de PE).
const fraisPerPlat = db.prepare(`
SELECT f.plateforme_id,
COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
${asOf ? 'AND f.date_operation <= ?' : ''}
GROUP BY f.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Corrections de solde (micro-ecarts fiscaux sur plateformes flat_tax)
const correctionsPerPlat = db.prepare(`
SELECT plateforme_id,
@@ -141,6 +186,8 @@ 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;
const cashByPlatformEnriched = cashByPlatform.map(p => ({
@@ -340,7 +387,31 @@ router.get('/solde-historique', (req, res) => {
AND i.date_souscription <= ?
`).get(date, platId, ...invParams, date);
/* ── 5. Corrections de solde (jusqu'à :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, même
// exclusion des frais PE 'source' (déjà inclus dans montant_investi, cf. commentaire ci-dessus).
const frais = db.prepare(`
SELECT COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE f.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
AND f.date_operation <= ?
`).get(platId, ...invParams, date);
/* ── 6. Corrections de solde (jusqu'à :date) ───────────────────── */
const corrections = db.prepare(`
SELECT COALESCE(SUM(montant), 0) AS total_correction
FROM corrections_solde
@@ -353,6 +424,8 @@ 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;
@@ -923,11 +996,14 @@ router.get('/detail-cellule', (req, res) => {
SELECT
r.id, r.investissement_id, r.date_remb,
r.capital, r.cashback,
r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
r.interets_bruts, r.frais_ttc, r.prelev_sociaux, r.prelev_forfaitaire,
r.interets_nets, r.net_recu,
i.nom_projet, i.plateforme_id,
p.nom AS plateforme_nom,
inv.nom AS detenteur_nom
inv.nom AS detenteur_nom,
-- Étape 5 bis, 01/09/26 : le frais n'est pas toujours réglé SUR ce remboursement (cf.
-- remboursements.js, computeChamps/syncFraisDistribution) — pilote l'icône/popup de détail.
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
+524
View File
@@ -0,0 +1,524 @@
import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
// requireAuth appliqué dans server.js
/**
* routes/dashboardPatrimoine.js — vue consolidée du patrimoine par regroupement (workspace
* "Patrimoine", chantier du 28/09/26, demande Olivier : "j'aimerais que l'on réfléchisse sur le
* comment la restitution du patrimoine va se passer... un espace de travail particulier :
* 'Patrimoine'"). Lecture seule, scope utilisateur (req.user.id) — pas de CRUD ici, seulement
* de l'agrégation en lecture sur des données déjà gérées ailleurs (Paramètres > Mes comptes,
* Emprunts, Admin > Enveloppes & regroupements).
*
* Cadrage validé par Olivier (AskUserQuestion, 28/09/26 (suite)) :
* - Respecter l'accès utilisateur : Crowdlending/Private Equity ne sont montrés/agrégés que
* si l'utilisateur a effectivement accès à ces workspaces (granted_by_admin=1 — pas
* seulement actif_by_user, cf. userHasWorkspaceAccess ci-dessous).
* - Un seul dashboard consolidé pour démarrer (pas de sous-pages par regroupement en v1).
*
* Périmètre :
* - Comptes courants / Comptes d'épargne / Comptes d'investissement / Immobilier / Crypto /
* Autres actifs : somme de comptes.solde_eur (saisie manuelle, cf. migration solde_eur dans
* db/index.js) rattachés via comptes.enveloppe_id → enveloppes_referentiel
* .regroupement_patrimonial_id (cf. plan_regroupements_patrimoniaux.md).
* - Passif : somme de emprunts.capital_restant_du.
* - Crowdlending et Private Equity : PAS des comptes.solde_eur (ces 2 regroupements n'ont
* aucune enveloppe eligible_compte=1, cf. `structurellement_vide` ci-dessous) — le total
* vient directement du portefeuille réel de leur workspace dédié, avec EXACTEMENT la même
* métrique que celle déjà affichée là-bas (crowdlendingCapitalInvesti /
* privateEquityCapitalInvesti ci-dessous — pas une nouvelle définition, un ré-affichage à
* l'identique). Ajouté le 29/09/26 suite à la remarque d'Olivier, qui s'attendait à voir
* cette valeur dès la v1 initiale (celle-ci se contentait d'un placeholder
* `portefeuille_a_venir`). N'apparaît que si l'utilisateur a effectivement accès au
* workspace correspondant (`access.crowdlending` / `access.private_equity`) — sinon le nœud
* reste `portefeuille_a_venir: true` avec un total à 0, et le frontend le masque entièrement.
*
* `structurellement_vide` (aucune enveloppe eligible_compte=1 dans ce regroupement, cf.
* migration eligible_compte, db/index.js — le sélecteur "Enveloppe" de Mes comptes ne propose
* QUE les enveloppes eligible_compte=1) signale un regroupement qui ne peut recevoir AUCUN
* solde via comptes.solde_eur, quoi que l'utilisateur saisisse : aujourd'hui Immobilier et
* Autres actifs (0 enveloppe éligible chacun — vérifié empiriquement ci-dessous, pas codé en
* dur). Crowdlending et Private Equity ont eux aussi 0 enveloppe éligible mais ne portent PAS
* ce flag : leur total vient du portefeuille réel (ci-dessus), pas d'une saisie manuelle en
* attente. Distinct d'un total à 0 € réellement saisi : le frontend doit l'afficher
* explicitement ("pas encore de saisie possible ici") plutôt qu'un "0 €" qui laisserait croire
* que le patrimoine de cette famille est nul.
*
* `total_eur` d'un regroupement PARENT (aujourd'hui : "Comptes d'investissement", seul
* regroupement à avoir un enfant, "Private Equity") est un total ROULÉ, cf. buildNode :
* total propre du parent (ses comptes.solde_eur rattachés directement) + total de chacun de
* ses enfants. Corrigé le 29/09/26 (retour d'Olivier : "la somme des sous-catégories doit se
* retrouver remontée en total au niveau de 'comptes d'investissement'" — bug de la v1, qui
* affichait le total du parent sans ses enfants).
*
* `lignes` (29/09/26, retour d'Olivier : "je voudrais... un système... pour développer soit par
* plateforme ou par compte/livret/produit") — détail par regroupement pour l'affichage
* dépliable côté frontend : liste des comptes (nom, établissement, détenteur, solde) pour les
* regroupements alimentés par comptes.solde_eur, liste des emprunts pour Passif, et répartition
* par plateforme (capital investi, nb d'investissements, détenteur agrégé) pour Crowdlending/Private Equity —
* "je m'attendrais à voir au moins le capital investi par plateforme". Pas de colonne
* variation/performance (contrairement aux captures d'écran d'app tierce qu'Olivier a
* partagées en référence) : comptes.solde_eur et emprunts.capital_restant_du sont des valeurs
* instantanées, sans historique conservé en base — un vrai suivi de performance nécessiterait
* de capturer un solde à chaque date (hors périmètre de ce chantier).
*
* Scope investisseur (29/09/26, correctif) — Olivier, sélecteur "profil actif" de la sidebar
* (InvestisseurContext.jsx) réglé sur "Capucine CROGUENNEC" : *"La sélection d'un détenteur ne
* semble pas filtrer le patrimoine."* La v1 ignorait complètement ce sélecteur et agrégeait
* systématiquement TOUS les investisseurs du foyer, quel que soit le profil actif — alors que
* /api/dashboard (Crowdlending) et /api/dashboard-pe respectent tous les deux le même contrat :
* `?scope=all` agrège tout le foyer, sinon on filtre strictement sur l'investisseur donné par
* le header `X-Investisseur-Id` (envoyé automatiquement par frontend/src/api.js à partir du
* `localStorage` que gère InvestisseurContext — cf. routes/dashboard.js pour le même motif).
* Cette route applique désormais le même contrat, cf. resolveInvestisseurScope ci-dessous, mais
* avec DEUX variantes de clause selon la table (distinction qui n'existe pas dans
* routes/dashboard.js, où toutes les tables scopées n'ont pas de user_id propre) :
* - `invClauseStrict` (investissements, investissements_pe) : ces tables n'ont PAS de colonne
* user_id — c'est le filtre investisseur qui délimite la frontière de propriété entre
* utilisateurs, pas seulement un filtre d'affichage. Il reste donc actif même en scope="all"
* (`investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)`), pour ne jamais
* remonter les investissements d'un autre utilisateur.
* - `invClauseOptional` (comptes, emprunts) : ces tables ont déjà leur propre `user_id`
* (`WHERE c.user_id = ?` déjà présent) — la frontière de propriété est donc assurée sans le
* filtre investisseur. En scope="all", ce filtre est donc omis entièrement (pas de
* IN-subquery) pour que le "Total actif" en vue "toute la famille" inclue aussi les
* comptes/emprunts sans détenteur assigné (investisseur_id NULL, ON DELETE SET NULL) — sans
* quoi le total en vue famille serait plus bas que la somme des vues individuelles, un
* comportement qui aurait semblé tout aussi cassé que le bug d'origine.
*/
function userHasWorkspaceAccess(userId, type) {
const row = db.prepare(`
SELECT 1 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.type = ?
LIMIT 1
`).get(userId, type);
return !!row;
}
// Résout le scope investisseur de la requête — même contrat que GET /api/dashboard (cf.
// routes/dashboard.js, section "Résolution de l'investisseur cible"), avec deux variantes de
// clause selon la table cible (cf. commentaire d'en-tête, section "Scope investisseur").
// Retourne soit { error: [status, body] } si le header est manquant/invalide (l'appelant doit
// alors répondre avec res.status(...).json(...) et s'arrêter), soit :
// - invClauseStrict(alias) / invParamsStrict — pour investissements/investissements_pe (pas de
// user_id propre : le filtre investisseur EST la frontière de propriété, reste actif même en
// scope="all").
// - invClauseOptional(alias) / invParamsOptional — pour comptes/emprunts (ont déjà leur propre
// user_id : en scope="all", `invClauseOptional` retourne '1=1', aucun filtre investisseur
// supplémentaire — inclut aussi les lignes sans détenteur assigné).
// Un seul jeu de chaque paramètres, réutilisable pour toutes les requêtes de cette route
// puisqu'il ne dépend que du scope résolu une fois en tête de route.
function resolveInvestisseurScope(req, userId) {
if (req.query.scope === 'all') {
return {
invClauseStrict: (alias) => `${alias}.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)`,
invParamsStrict: [userId],
invClauseOptional: () => '1=1',
invParamsOptional: [],
};
}
const raw = req.header('X-Investisseur-Id');
const invId = Number(raw);
if (!invId) {
return { error: [400, { 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 { error: [403, { error: 'Investisseur not found or not owned by user' }] };
}
return {
invClauseStrict: (alias) => `${alias}.investisseur_id = ?`,
invParamsStrict: [invId],
invClauseOptional: (alias) => `${alias}.investisseur_id = ?`,
invParamsOptional: [invId],
};
}
// Regroupements dont le total ne vient pas de comptes.solde_eur mais du portefeuille réel du
// workspace dédié — cf. commentaire d'en-tête. Identifiés par nom (pas d'autre marqueur en base
// à ce jour) plutôt que par une colonne dédiée : seul cas d'usage pour l'instant, ne justifie
// pas une migration.
const PORTEFEUILLE_NOMS = new Set(['Crowdlending', 'Private Equity']);
// "Capital investi" Crowdlending (29/09/26, suite à la remarque d'Olivier qui s'attendait à
// voir cette valeur dès la v1) — reprend EXACTEMENT la même métrique que le KPI "Capital
// investi" du Dashboard crowdlending (Dashboard.jsx : portfolio.encours + portfolio.en_defaut,
// cf. routes/dashboard.js) : capital actuellement engagé (statuts en_cours/en_retard/procedure
// uniquement — exclut rembourse/cloture), net des réinvestissements et remboursements de
// capital. Pas une nouvelle définition : un ré-affichage à l'identique. Scope investisseur
// (29/09/26, correctif) : agrégé sur le foyer entier ou un seul investisseur selon
// resolveInvestisseurScope, comme comptes/emprunts ailleurs dans ce fichier — plus
// systématiquement "tous les investisseurs de l'utilisateur".
const CL_CAPITAL_CASE = `CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
ELSE 0 END`;
function crowdlendingCapitalInvesti(invClauseStrict, invParamsStrict) {
const row = db.prepare(`
SELECT COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS capital_investi
FROM investissements i
WHERE ${invClauseStrict('i')}
`).get(...invParamsStrict);
return row.capital_investi;
}
// Détenteur agrégé pour une ligne groupée par plateforme (Crowdlending/PE) : GROUP_CONCAT
// DISTINCT ramène tous les détenteurs distincts séparés par virgule — un seul nom si tous les
// investissements de la plateforme partagent le même détenteur, "Plusieurs" s'ils divergent
// (foyer avec plusieurs investisseurs sur la même plateforme — ne peut plus se produire quand
// le scope est un investisseur unique, cf. resolveInvestisseurScope : seul son propre nom peut
// alors apparaître).
//
// Corrigé le 29/09/26 (suite, suite) : `inv.nom` seul, plus de concaténation avec `inv.prenom`.
// Olivier a signalé un second problème sur sa base réelle une fois la colonne Détenteur
// remplie ("Encore un pb sur l'affichage des détenteurs") : "Olivier Olivier CROGUENNEC" au
// lieu de "Olivier CROGUENNEC". Cause : investisseurs.nom stocke déjà le NOM COMPLET
// (prénom + nom de famille) pour un profil "famille" — cf. INSERT dans auth.js (création du
// profil principal à l'inscription : `nom = fullName`, `prenom` extrait séparément du même
// fullName) et FamilleEntreprises.jsx (le formulaire d'édition reconstruit `nom_famille` en
// retirant le prénom de `nom` : `m.nom.replace(m.prenom, '')`, preuve que `nom` contient déjà
// tout). Pour un profil "entreprise", `prenom` est NULL et `nom` est simplement la raison
// sociale. Dans les deux cas, `inv.nom` seul EST le nom d'affichage — le préfixer par
// `inv.prenom` (comme le faisaient encore `comptesLignesStmt`/`empruntsLignesStmt` plus bas,
// corrigés au même moment) dupliquait le prénom. Vérifié en lecture seule sur une copie locale
// de la base réelle d'Olivier (investisseurs.nom = "Olivier CROGUENNEC", "Capucine CROGUENNEC",
// "Marine CROGUENNEC" — jamais juste le nom de famille).
function toDetenteur(concat) {
if (!concat) return null;
const noms = [...new Set(concat.split(',').map(s => s.trim()).filter(Boolean))];
if (noms.length === 0) return null;
if (noms.length === 1) return noms[0];
return 'Plusieurs';
}
// Répartition du capital investi Crowdlending par plateforme (même métrique que
// crowdlendingCapitalInvesti ci-dessus, groupée) — demande Olivier 29/09/26 : "je m'attendrais
// à voir au moins le capital investi par plateforme". `detenteur` ajouté le 29/09/26 (suite) :
// Olivier a signalé que la colonne Détenteur restait vide sur ces lignes ("Cela n'a pas l'air
// de fonctionner pour les détenteurs") — chaque investissement porte bien un investisseur_id
// (détenteur), l'agrégation par plateforme se contentait jusque-là de ne pas le remonter.
// Scope investisseur (29/09/26, correctif) : `invClauseStrict`/`invParamsStrict` remplacent le
// filtrage fixe "tous les investisseurs de l'utilisateur", cf. resolveInvestisseurScope
// (variante stricte : investissements n'a pas de user_id propre).
function crowdlendingLignes(invClauseStrict, invParamsStrict) {
const rows = db.prepare(`
SELECT p.id AS id, p.nom AS label,
COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS valeur_eur,
COUNT(CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN 1 END) AS nb_lignes,
GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat,
p.icone_filename AS icone_filename,
p.logo_filename AS logo_filename
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE ${invClauseStrict('i')}
GROUP BY p.id, p.nom
HAVING valeur_eur > 0
ORDER BY valeur_eur DESC
`).all(...invParamsStrict);
return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) }));
}
// "Capital investi total" Private Equity — reprend EXACTEMENT la même métrique que le KPI
// "Capital investi total" du Dashboard PE (DashboardPe.jsx : somme de montant_investi sur tous
// les deals, tous statuts confondus — modèle PE différent du crowdlending, pas d'échéancier ni
// de notion d'encours net). Tous workspaces de type private_equity confondus (le type peut
// avoir plusieurs exemplaires, cf. adminWorkspaces.js). Scope investisseur (29/09/26,
// correctif) : cf. crowdlendingCapitalInvesti ci-dessus, même principe.
function privateEquityCapitalInvesti(invClauseStrict, invParamsStrict) {
const row = db.prepare(`
SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi
FROM investissements_pe ipe
WHERE ${invClauseStrict('ipe')}
`).get(...invParamsStrict);
return row.capital_investi;
}
// `detenteur` ajouté le 29/09/26 — même correction que crowdlendingLignes ci-dessus. Scope
// investisseur (29/09/26, correctif) : idem crowdlendingLignes (variante stricte).
function privateEquityLignes(invClauseStrict, invParamsStrict) {
const rows = db.prepare(`
SELECT p.id AS id, p.nom AS label,
COALESCE(SUM(ipe.montant_investi), 0) AS valeur_eur,
COUNT(*) AS nb_lignes,
GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat,
p.icone_filename AS icone_filename,
p.logo_filename AS logo_filename
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invClauseStrict('ipe')}
GROUP BY p.id, p.nom
HAVING valeur_eur > 0
ORDER BY valeur_eur DESC
`).all(...invParamsStrict);
return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) }));
}
router.get('/', (req, res) => {
const userId = req.user.id;
const scope = resolveInvestisseurScope(req, userId);
if (scope.error) {
const [status, body] = scope.error;
return res.status(status).json(body);
}
const { invClauseStrict, invParamsStrict, invClauseOptional, invParamsOptional } = scope;
const roots = db.prepare(`
SELECT id, nom, parent_id, type, ordre_affichage
FROM regroupements_patrimoniaux
WHERE parent_id IS NULL
ORDER BY ordre_affichage, nom
`).all();
const children = db.prepare(`
SELECT id, nom, parent_id, type, ordre_affichage
FROM regroupements_patrimoniaux
WHERE parent_id IS NOT NULL
ORDER BY ordre_affichage, nom
`).all();
// Scope investisseur (29/09/26, correctif) : `AND ${invClauseOptional('c')}` /
// `AND ${invClauseOptional('e')}` ajoutés aux 4 requêtes comptes/emprunts ci-dessous, params
// complétés par `...invParamsOptional` — cf. resolveInvestisseurScope et commentaire
// d'en-tête. Variante OPTIONNELLE (pas stricte) : comptes/emprunts ont déjà leur propre
// user_id, donc en scope="all" aucun filtre investisseur n'est ajouté (comptes.investisseur_id
// et emprunts.investisseur_id sont tous deux nullable, ON DELETE SET NULL, cf. db/index.js —
// un compte/emprunt sans détenteur assigné doit rester compté dans le "Total actif" de la vue
// "toute la famille"). En scope investisseur unique, la clause redevient stricte
// (`investisseur_id = ?`) — un compte sans détenteur assigné n'apparaît alors dans AUCUNE vue
// individuelle, ce qui est le comportement attendu.
const soldeStmt = db.prepare(`
SELECT
COALESCE(SUM(c.solde_eur), 0) AS total,
SUM(CASE WHEN c.solde_eur IS NOT NULL THEN 1 ELSE 0 END) AS nb_avec_solde,
SUM(CASE WHEN c.solde_eur IS NULL THEN 1 ELSE 0 END) AS nb_sans_solde
FROM comptes c
JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id
WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ? AND ${invClauseOptional('c')}
`);
// Lignes de détail "par compte" — établissement (institution du référentiel, sinon le champ
// texte libre banque) et détenteur, pour l'affichage dépliable (cf. commentaire d'en-tête).
// 30/09/26, retour d'Olivier ("La déclaration des comptes courant faite dans les paramètres
// n'apparaissent pas") : remonte désormais TOUS les comptes rattachés, y compris ceux sans
// solde saisi (valeur_eur alors NULL, cf. fmtEUR/fmtPct côté frontend) — un compte peut être
// "déclaré" (référencé, avec sa banque et son détenteur) sans que son solde soit suivi ; le
// filtre solde_eur IS NOT NULL les faisait disparaître entièrement de la liste, ce qui n'était
// pas ce qu'Olivier voulait. Les comptes sans solde sont triés en dernier.
const comptesLignesStmt = db.prepare(`
SELECT c.id AS id, c.nom AS label,
COALESCE(ir.nom, c.banque) AS sous_label,
c.solde_eur AS valeur_eur,
inv.nom AS detenteur,
ir.icone_filename AS icone_filename,
ir.logo_filename AS logo_filename
FROM comptes c
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id
WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ? AND ${invClauseOptional('c')}
ORDER BY (c.solde_eur IS NULL), c.solde_eur DESC
`);
const eligibleCountStmt = db.prepare(`
SELECT COUNT(*) AS n FROM enveloppes_referentiel
WHERE regroupement_patrimonial_id = ? AND eligible_compte = 1
`);
const passifStmt = db.prepare(`
SELECT COALESCE(SUM(e.capital_restant_du), 0) AS total, COUNT(*) AS n
FROM emprunts e WHERE e.user_id = ? AND e.regroupement_patrimonial_id = ? AND ${invClauseOptional('e')}
`);
const empruntsLignesStmt = db.prepare(`
SELECT e.id AS id, e.nom AS label,
ir.nom AS sous_label,
e.capital_restant_du AS valeur_eur,
inv.nom AS detenteur,
ir.icone_filename AS icone_filename,
ir.logo_filename AS logo_filename
FROM emprunts e
LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id
LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id
WHERE e.user_id = ? AND e.regroupement_patrimonial_id = ? AND ${invClauseOptional('e')}
ORDER BY e.capital_restant_du DESC
`);
// Lignes patrimoniales directes (chantier "Visualisation d'un actif", relance Phase 3b,
// 30/09/26 — cf. commentaire de migration lignes_patrimoine/transactions_patrimoine dans
// db/index.js et claude/plan_visualisation_actif.md). Couvre le second chemin de
// rattachement déjà validé (enveloppe rattachée directement, sans compte intermédiaire) :
// une position patrimoniale pour les enveloppes sans aucun moyen de recevoir une valeur via
// comptes.solde_eur (Immobilier, Autres actifs, Crypto hors exchange...). `nb_transactions`
// et `valeur_calculee` permettent au code JS ci-dessous (buildNode) de choisir : valeur
// calculée depuis l'historique de transactions_patrimoine quand il existe, sinon repli sur
// la saisie directe `lp.valeur_eur` (cf. commentaire de migration pour la convention de
// signe : dépôt/achat ajoutent, retrait/vente retranchent, reevaluation est une variation
// signée directe).
const lignesPatrimoineStmt = db.prepare(`
SELECT lp.id AS id, lp.nom AS label,
ir.nom AS sous_label,
inv.nom AS detenteur,
ir.icone_filename AS icone_filename,
ir.logo_filename AS logo_filename,
lp.valeur_eur AS valeur_eur_saisie,
(SELECT COUNT(*) FROM transactions_patrimoine tp WHERE tp.ligne_patrimoine_id = lp.id) AS nb_transactions,
(SELECT COALESCE(SUM(CASE tp.type
WHEN 'depot' THEN tp.montant
WHEN 'achat' THEN tp.montant
WHEN 'reevaluation' THEN tp.montant
WHEN 'retrait' THEN -tp.montant
WHEN 'vente' THEN -tp.montant
END), 0)
FROM transactions_patrimoine tp WHERE tp.ligne_patrimoine_id = lp.id) AS valeur_calculee
FROM lignes_patrimoine lp
JOIN enveloppes_referentiel er ON er.id = lp.enveloppe_id
LEFT JOIN institutions_referentiel ir ON ir.id = lp.institution_id
LEFT JOIN investisseurs inv ON inv.id = lp.investisseur_id
WHERE lp.user_id = ? AND er.regroupement_patrimonial_id = ? AND ${invClauseOptional('lp')}
ORDER BY lp.nom
`);
// Tri commun des lignes d'un regroupement "actif" une fois comptes + lignes_patrimoine
// fusionnées (chacune était déjà triée individuellement côté SQL, mais la fusion casse
// l'ordre global) : valeur décroissante, valeurs non renseignées (null) en dernier — même
// règle que ORDER BY (c.solde_eur IS NULL), c.solde_eur DESC ci-dessus.
function sortLignes(arr) {
return [...arr].sort((a, b) => {
if (a.valeur_eur == null && b.valeur_eur == null) return 0;
if (a.valeur_eur == null) return 1;
if (b.valeur_eur == null) return -1;
return b.valeur_eur - a.valeur_eur;
});
}
const accessCache = {
crowdlending: userHasWorkspaceAccess(userId, 'crowdlending'),
private_equity: userHasWorkspaceAccess(userId, 'private_equity'),
};
function buildNode(r) {
const isPortefeuille = PORTEFEUILLE_NOMS.has(r.nom);
const eligibleCount = eligibleCountStmt.get(r.id).n;
let ownTotalEur = 0, nbAvecSolde = 0, nbSansSolde = 0, sourcePortefeuille = false, portefeuilleAVenir = false;
let lignes = [];
// Nombre de lignes_patrimoine rattachées à ce regroupement (quelle que soit leur valeur) —
// utilisé ci-dessous pour corriger structurellement_vide : une ligne_patrimoine réellement
// créée prouve que la saisie n'est plus structurellement impossible pour ce regroupement,
// même si aucune enveloppe n'y est eligible_compte=1.
let nbLignesPatrimoine = 0;
if (r.nom === 'Crowdlending') {
if (accessCache.crowdlending) {
ownTotalEur = crowdlendingCapitalInvesti(invClauseStrict, invParamsStrict);
sourcePortefeuille = true;
lignes = crowdlendingLignes(invClauseStrict, invParamsStrict).map(x => ({ ...x, type: 'plateforme_cl' }));
} else {
portefeuilleAVenir = true;
}
} else if (r.nom === 'Private Equity') {
if (accessCache.private_equity) {
ownTotalEur = privateEquityCapitalInvesti(invClauseStrict, invParamsStrict);
sourcePortefeuille = true;
lignes = privateEquityLignes(invClauseStrict, invParamsStrict).map(x => ({ ...x, type: 'plateforme_pe' }));
} else {
portefeuilleAVenir = true;
}
} else if (r.type === 'actif') {
const s = soldeStmt.get(userId, r.id, ...invParamsOptional);
// `?? 0` (30/09/26, chantier "Visualisation d'un actif", corrigé au passage) :
// SUM(CASE ...) sur 0 ligne renvoie NULL en SQLite (pas de COALESCE dans soldeStmt pour
// ces 2 colonnes, contrairement à `total`) — jusqu'ici sans conséquence visible car un
// regroupement avec 0 compte est presque toujours structurellement_vide (et le frontend
// ne lit alors pas ces champs, cf. valeurRegroupement dans DashboardPatrimoine.jsx).
// Devient significatif avec lignes_patrimoine ci-dessous, qui incrémente ces mêmes
// compteurs pour des regroupements qui n'ont justement AUCUN compte (Immobilier, Autres
// actifs...) : sans ce repli, `nbSansSolde` resterait `null` au lieu de `0` dès qu'une
// ligne_patrimoine est ajoutée, et `node.nb_comptes_sans_solde === 0` (comparaison
// stricte côté frontend) échouerait à tort.
ownTotalEur = s.total; nbAvecSolde = s.nb_avec_solde ?? 0; nbSansSolde = s.nb_sans_solde ?? 0;
const comptesLignes = comptesLignesStmt.all(userId, r.id, ...invParamsOptional)
.map(x => ({ ...x, type: 'compte' }));
const lignesPatRaw = lignesPatrimoineStmt.all(userId, r.id, ...invParamsOptional);
nbLignesPatrimoine = lignesPatRaw.length;
const lignesPat = lignesPatRaw.map(x => {
// Valeur calculée depuis l'historique de transactions quand il en existe au moins
// une, sinon repli sur la saisie directe (cf. commentaire de lignesPatrimoineStmt).
const valeur = x.nb_transactions > 0 ? x.valeur_calculee : x.valeur_eur_saisie;
if (valeur !== null && valeur !== undefined) { nbAvecSolde += 1; ownTotalEur += valeur; }
else { nbSansSolde += 1; }
return {
id: x.id, label: x.label, sous_label: x.sous_label, valeur_eur: valeur,
detenteur: x.detenteur, icone_filename: x.icone_filename, logo_filename: x.logo_filename,
type: 'ligne_patrimoine',
};
});
lignes = sortLignes([...comptesLignes, ...lignesPat]);
} else {
const p = passifStmt.get(userId, r.id, ...invParamsOptional);
ownTotalEur = p.total; nbAvecSolde = p.n; nbSansSolde = 0;
lignes = empruntsLignesStmt.all(userId, r.id, ...invParamsOptional).map(x => ({ ...x, type: 'emprunt' }));
}
const enfants = children.filter(ch => ch.parent_id === r.id).map(buildNode);
// Total ROULÉ (29/09/26, correctif) : propre total + total de chaque enfant — un enfant
// (ex. Private Equity) n'a lui-même pas d'enfant (hiérarchie à 2 niveaux max, cf.
// regroupementsPatrimoniaux.js), donc pas de risque de double-comptage plus profond.
const totalEur = ownTotalEur + enfants.reduce((s, e) => s + e.total_eur, 0);
// structurellement_vide ROULÉ (30/09/26, correctif) : un regroupement parent peut n'avoir
// aucune enveloppe éligible directement rattachée tout en ayant des enfants qui, eux, en
// ont (ex. "Comptes courants" une fois scindé en "Comptes courants rémunérés"/"non
// rémunérés", cf. claude/plan_regroupements_patrimoniaux.md) — dans ce cas le parent NE
// DOIT PAS être marqué structurellement vide, puisque son total roulé (ci-dessus) reste
// significatif. Calculé après `enfants` (donc après leurs propres structurellement_vide) :
// vide seulement si le nœud lui-même n'a pas d'enveloppe éligible ET que tous ses enfants
// (potentiellement aucun) sont eux-mêmes structurellement vides — un nœud sans enfant se
// comporte donc exactement comme avant (Immobilier, Autres actifs restent "—").
// Correctif (30/09/26, chantier "Visualisation d'un actif") : une ligne_patrimoine
// réellement créée (nbLignesPatrimoine > 0) sort le regroupement de l'état
// structurellement_vide, même sans enveloppe eligible_compte=1 — la saisie n'est plus
// "impossible quoi que l'utilisateur saisisse" (cf. commentaire d'en-tête de ce fichier)
// puisqu'elle a effectivement eu lieu via ce second chemin.
const structurellementVide = !isPortefeuille && eligibleCount === 0 && nbLignesPatrimoine === 0
&& enfants.every(e => e.structurellement_vide);
return {
id: r.id,
nom: r.nom,
type: r.type,
ordre_affichage: r.ordre_affichage,
total_eur: totalEur,
nb_comptes_avec_solde: nbAvecSolde,
nb_comptes_sans_solde: nbSansSolde,
structurellement_vide: structurellementVide,
portefeuille_a_venir: portefeuilleAVenir,
source_portefeuille: sourcePortefeuille,
lignes,
enfants,
};
}
const tree = roots.map(buildNode);
// Les totaux racine sont désormais roulés (ils incluent déjà leurs enfants, cf. buildNode) —
// une simple somme des racines par type suffit, plus besoin de descendre récursivement dans
// l'arbre (la hiérarchie ne dépasse de toute façon pas 2 niveaux, cf. plus haut).
const totalActif = tree.filter(n => n.type === 'actif').reduce((s, n) => s + n.total_eur, 0);
const totalPassif = tree.filter(n => n.type === 'passif').reduce((s, n) => s + n.total_eur, 0);
res.json({
access: accessCache,
total_actif_eur: totalActif,
total_passif_eur: totalPassif,
net_eur: totalActif - totalPassif,
regroupements: tree,
});
});
export default router;
+227
View File
@@ -0,0 +1,227 @@
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.
// Même correctif que dashboard.js (19/09/26) : le JOIN ne portait que sur les investissements
// crowdlending, donc les frais PE (investissement_pe_id) n'étaient jamais déduits du solde
// porte-monnaie — surestimation silencieuse sur toute plateforme partagée avec des frais PE
// "source"/"portefeuille". LEFT JOIN + COALESCE des deux investisseur_id possibles.
// Correctif du même jour (Olivier, après coup) : en fait seul 'portefeuille' doit compter côté
// PE — 'source' est déjà inclus dans montant_investi (le montant BRUT transféré à la
// souscription), contrairement au crowdlending où montant_investi est net (cf. dashboard.js).
const fraisPerPlat = db.prepare(`
SELECT f.plateforme_id,
COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = '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); }
+371
View File
@@ -0,0 +1,371 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { createZip, sanitizeZipPart } from '../utils/zip.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── Stockage fichiers ──────────────────────────────────────────────────────
// Même convention que backend/src/routes/tickets.js (pièces jointes support) :
// répertoire plat sous data/, nom de fichier sur disque randomisé, nom
// d'origine + métadonnées conservés en base pour l'affichage/téléchargement.
const dataDir = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: path.resolve(__dirname, '../../../data');
// Exporté : réutilisé par investissements.js pour lire les fichiers lors de
// l'export ZIP d'un dossier d'investissement (manifest.json + documents).
export const docsDir = path.join(dataDir, 'documents');
fs.mkdirSync(docsDir, { recursive: true });
// ── Quotas (par utilisateur/compte — même valeur pour tous les comptes,
// pas de personnalisation individuelle pour l'instant). Configurables depuis
// Admin > Général (colonnes documents_quota_bytes/documents_quota_count sur
// smtp_config, cf. migration dans db/index.js) — getQuotaConfig() relit la
// valeur courante à chaque requête, DEFAULT ci-dessous seulement si la ligne
// smtp_config n'existe pas encore (Général jamais ouvert). Le plafond par
// fichier reste une constante — non demandé configurable. ─────────────────
const DEFAULT_QUOTA_BYTES = 5 * 1024 * 1024 * 1024; // 5 Go
const DEFAULT_QUOTA_COUNT = 500;
export const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 Mo par fichier — exporté : réutilisé par imports.js pour vérifier chaque document d'un dossier importé
export function getQuotaConfig() {
const row = db.prepare('SELECT documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get();
return {
quotaBytes: row?.documents_quota_bytes || DEFAULT_QUOTA_BYTES,
quotaCount: row?.documents_quota_count || DEFAULT_QUOTA_COUNT,
};
}
const storage = multer.diskStorage({
destination: (_, __, cb) => cb(null, docsDir),
filename: (_, file, cb) => {
const ext = path.extname(file.originalname);
const base = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
cb(null, base + ext);
},
});
const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES } });
const router = Router();
/** Usage courant (nombre de documents + octets) pour un utilisateur.
* Exporté : réutilisé par imports.js (import de dossier d'investissement)
* pour vérifier le quota avant d'écrire les documents du zip importé. */
export function getUsage(userId) {
return db.prepare(
'SELECT COUNT(*) AS used_count, COALESCE(SUM(taille_octets),0) AS used_bytes FROM documents WHERE user_id = ?'
).get(userId);
}
/* ── GET /api/documents ──────────────────────────────────────────────────
Sans filtre : tous les documents du compte (utilisé par "Mon compte" —
décision Olivier : la vue d'ensemble couvre tous les investisseurs du
compte, pas seulement l'investisseur actif), avec le contexte investissement
/investisseur en plus pour s'y retrouver.
Avec ?entity_type=&entity_id= : documents d'une entité précise (utilisé par
le bloc "Mes documents" de la fiche investissement). ─────────────────── */
router.get('/', (req, res, next) => {
try {
const { entity_type, entity_id } = req.query;
let rows;
if (entity_type && entity_id) {
rows = db.prepare(
'SELECT * FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ? ORDER BY created_at DESC'
).all(req.user.id, entity_type, Number(entity_id));
} else {
rows = db.prepare(`
SELECT d.*, i.nom_projet AS investissement_nom, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
FROM documents d
LEFT JOIN investissements i ON d.entity_type = 'investissement' AND i.id = d.entity_id
LEFT JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE d.user_id = ?
ORDER BY d.created_at DESC
`).all(req.user.id);
}
res.json(rows);
} catch (e) { next(e); }
});
/* ── GET /api/documents/quota ────────────────────────────────────────────
Consommation courante + limites, pour l'affichage dans "Mon compte". ── */
router.get('/quota', (req, res, next) => {
try {
const usage = getUsage(req.user.id);
const { quotaBytes, quotaCount } = getQuotaConfig();
res.json({
used_bytes: usage.used_bytes,
used_count: usage.used_count,
quota_bytes: quotaBytes,
quota_count: quotaCount,
max_file_bytes: MAX_FILE_BYTES,
});
} catch (e) { next(e); }
});
/* ── GET /api/documents/export ───────────────────────────────────────────
Export ZIP de tous les documents du compte, organisés en dossiers selon
la même logique de regroupement que "Mon compte" > "Mes documents" côté
frontend : <Catégorie>/<Projet>/<fichier> pour les documents liés à un
investissement, <Catégorie>/<fichier> sinon — demande Olivier 29/08/26.
Utilise le builder ZIP maison (backend/src/utils/zip.js, déjà utilisé par
les exports plateformes/référentiel), pas de dépendance externe. ─────── */
const CATEGORIE_FOLDER_LABELS = { investissement: 'Investissements', fiscalite: 'Fiscalite', autre: 'Autres' };
router.get('/export', (req, res, next) => {
try {
const rows = db.prepare(`
SELECT d.*, i.nom_projet AS investissement_nom
FROM documents d
LEFT JOIN investissements i ON d.entity_type = 'investissement' AND i.id = d.entity_id
WHERE d.user_id = ?
ORDER BY d.created_at DESC
`).all(req.user.id);
if (rows.length === 0) throw new HttpError(400, 'Aucun document à exporter');
const usedNamesByFolder = new Map(); // dossier -> Set des noms déjà pris (gestion des doublons)
const entries = [];
for (const doc of rows) {
const catFolder = sanitizeZipPart(CATEGORIE_FOLDER_LABELS[doc.categorie] || doc.categorie || 'Autres');
const folder = (doc.entity_type === 'investissement' && doc.entity_id)
? `${catFolder}/${sanitizeZipPart(doc.investissement_nom || `Investissement #${doc.entity_id}`)}`
: catFolder;
const baseLabel = sanitizeZipPart(doc.nom_affichage);
const used = usedNamesByFolder.get(folder) || new Set();
let finalName = `${baseLabel}.${doc.extension}`;
let n = 2;
while (used.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; }
used.add(finalName);
usedNamesByFolder.set(folder, used);
const filePath = path.join(docsDir, doc.filename);
if (!fs.existsSync(filePath)) continue; // fichier disparu du disque — ignoré plutôt que d'échouer tout l'export
entries.push({ name: `${folder}/${finalName}`, data: fs.readFileSync(filePath) });
}
const zipBuf = createZip(entries);
const filename = `documents-${new Date().toISOString().slice(0, 10)}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(zipBuf);
} catch (e) { next(e); }
});
/* ── POST /api/documents ─────────────────────────────────────────────────
multipart/form-data : file (obligatoire), entity_type, entity_id,
categorie (optionnelle), nom_affichage (optionnel — par défaut le nom du
fichier sans son extension, cf. demande Olivier). ────────────────────── */
router.post('/', (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') {
return next(new HttpError(400, `Le fichier dépasse la taille maximale autorisée (${Math.round(MAX_FILE_BYTES / 1024 / 1024)} Mo).`));
}
return next(err);
}
handleUpload(req, res, next);
});
});
function handleUpload(req, res, next) {
const cleanup = () => { if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* déjà absent */ } } };
try {
if (!req.file) throw new HttpError(400, 'Aucun fichier reçu');
const entityType = req.body.entity_type || null;
const entityId = req.body.entity_id ? Number(req.body.entity_id) : null;
const categorie = req.body.categorie?.trim() || (entityType === 'investissement' ? 'investissement' : 'autre');
// Un document lié à un investissement doit appartenir à l'utilisateur
// (même vérification de propriété que le reste de l'API — jointure via
// investisseurs.user_id, cf. backend/src/routes/investissements.js).
if (entityType === 'investissement') {
if (!entityId) throw new HttpError(400, 'entity_id est requis pour la catégorie investissement');
const owned = db.prepare(`
SELECT i.id FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.id = ?
`).get(req.user.id, entityId);
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) {
throw new HttpError(400, `Quota de documents atteint (${quotaCount} maximum) — supprimez des documents existants avant d'en ajouter de nouveaux.`);
}
if (usage.used_bytes + req.file.size > quotaBytes) {
throw new HttpError(400, `Quota de stockage dépassé (${(quotaBytes / 1024 / 1024 / 1024).toFixed(0)} Go maximum) — supprimez des documents existants avant d'en ajouter de nouveaux.`);
}
const ext = (path.extname(req.file.originalname).replace(/^\./, '') || 'bin').toLowerCase();
const nomOriginalSansExt = req.file.originalname.replace(/\.[^./\\]+$/, '');
const nomAffichage = req.body.nom_affichage?.trim() || nomOriginalSansExt || req.file.originalname;
const r = db.prepare(`
INSERT INTO documents (user_id, categorie, entity_type, entity_id, nom_affichage, nom_original, extension, mime_type, taille_octets, filename)
VALUES (?,?,?,?,?,?,?,?,?,?)
`).run(req.user.id, categorie, entityType, entityId, nomAffichage, req.file.originalname, ext, req.file.mimetype || null, req.file.size, req.file.filename);
res.status(201).json(db.prepare('SELECT * FROM documents WHERE id = ?').get(r.lastInsertRowid));
} catch (e) {
cleanup();
next(e);
}
}
/* ── PUT /api/documents/:id ──────────────────────────────────────────────
Renommage (nom_affichage uniquement — le fichier stocké et son extension
ne changent pas). ──────────────────────────────────────────────────── */
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!row) throw new HttpError(404, 'Document introuvable');
const nomAffichage = req.body?.nom_affichage?.trim();
if (!nomAffichage) throw new HttpError(400, 'nom_affichage est requis');
db.prepare("UPDATE documents SET nom_affichage = ?, updated_at = datetime('now') WHERE id = ?").run(nomAffichage, req.params.id);
res.json(db.prepare('SELECT * FROM documents WHERE id = ?').get(req.params.id));
} catch (e) { next(e); }
});
/* ── DELETE /api/documents ────────────────────────────────────────────────
"Tout effacer" — supprime TOUS les documents du compte, toutes catégories
et tous projets confondus (menu ⋮ du bloc "Mes documents" dans Mon compte)
— demande Olivier 29/08/26. L'avertissement d'irréversibilité est affiché
côté frontend avant l'appel (confirmation). ───────────────────────────── */
router.delete('/', (req, res, next) => {
try {
const rows = db.prepare('SELECT * FROM documents WHERE user_id = ?').all(req.user.id);
db.prepare('DELETE FROM documents WHERE user_id = ?').run(req.user.id);
for (const doc of rows) {
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
}
res.json({ deleted: rows.length });
} catch (e) { next(e); }
});
/**
* Supprime les documents d'une entité (lignes `documents` + fichiers sur
* disque) — factorisé pour être réutilisé hors de ce routeur : suppression
* d'un investissement (investissements.js) et purge des données d'une
* plateforme (plateformes.js), qui n'ont sinon aucun moyen de nettoyer les
* documents liés (entity_id est un lien polymorphe, sans contrainte de clé
* étrangère — rien ne les supprime automatiquement). Retourne le nombre de
* documents supprimés.
*/
export function deleteDocumentsForEntity(userId, entityType, entityId) {
const rows = db.prepare(
'SELECT * FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ?'
).all(userId, entityType, entityId);
db.prepare(
'DELETE FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ?'
).run(userId, entityType, entityId);
for (const doc of rows) {
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
}
return rows.length;
}
/**
* Efface du disque les fichiers de TOUS les documents d'un utilisateur, sans
* toucher aux lignes en base — à appeler juste AVANT un `DELETE FROM users`
* (suppression de compte, admin ou self-service) : les lignes `documents`
* sont supprimées automatiquement par la contrainte `ON DELETE CASCADE` sur
* `documents.user_id`, mais une cascade SQLite n'efface jamais les fichiers
* du disque — il faut donc les lister nous-mêmes pendant que le user (et ses
* documents) existent encore, puis les effacer. Retourne le nombre de
* fichiers effacés.
*/
export function deleteAllDocumentFilesForUser(userId) {
const rows = db.prepare('SELECT filename FROM documents WHERE user_id = ?').all(userId);
for (const doc of rows) {
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
}
return rows.length;
}
/**
* Construit les entrées zip pour TOUS les documents de TOUS les utilisateurs
* (pas de filtre user_id) — utilisé par l'export complet admin (route manuelle
* ET job planifié quotidien, cf. admin.js /export-full et jobs/autoExport.js) :
* ces exports embarquent déjà la base SQLite entière via VACUUM INTO (donc les
* lignes `documents`), mais jusqu'ici pas les fichiers physiques qu'elles
* référencent. Chaque fichier existant sur disque devient une entrée
* `documents/<filename>`, même convention que les dossiers `logos/`/`icons/`
* déjà présents dans cet export. Un fichier disparu du disque (référencé en
* base mais absent) est ignoré plutôt que de faire échouer tout l'export.
*/
export function buildAllDocumentsZipEntries() {
const rows = db.prepare('SELECT filename FROM documents').all();
const entries = [];
for (const { filename } of rows) {
const filePath = path.join(docsDir, filename);
if (fs.existsSync(filePath)) entries.push({ name: `documents/${filename}`, data: fs.readFileSync(filePath) });
}
return entries;
}
/* ── DELETE /api/documents/by-entity/:entityType/:entityId ───────────────
"Tout supprimer" scopé à une entité (ex. tous les documents d'un
investissement) — déclaré avant /:id pour éviter toute ambiguïté de route
bien que les deux formes ne se chevauchent pas (nombre de segments
différent). ─────────────────────────────────────────────────────────── */
router.delete('/by-entity/:entityType/:entityId', (req, res, next) => {
try {
const { entityType, entityId } = req.params;
if (entityType === 'investissement') {
const owned = db.prepare(`
SELECT i.id FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.id = ?
`).get(req.user.id, entityId);
if (!owned) throw new HttpError(404, 'Investissement introuvable');
}
const deleted = deleteDocumentsForEntity(req.user.id, entityType, Number(entityId));
res.json({ deleted });
} catch (e) { next(e); }
});
/* ── GET /api/documents/:id/download ─────────────────────────────────── */
router.get('/:id/download', (req, res, next) => {
try {
const doc = db.prepare('SELECT * FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!doc) throw new HttpError(404, 'Document introuvable');
const filePath = path.join(docsDir, doc.filename);
const safeName = `${doc.nom_affichage}.${doc.extension}`.replace(/["\r\n]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
if (doc.mime_type) res.setHeader('Content-Type', doc.mime_type);
res.sendFile(filePath, (err) => { if (err && !res.headersSent) next(err); });
} catch (e) { next(e); }
});
/* ── DELETE /api/documents/:id ────────────────────────────────────────── */
router.delete('/:id', (req, res, next) => {
try {
const doc = db.prepare('SELECT * FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!doc) throw new HttpError(404, 'Document introuvable');
db.prepare('DELETE FROM documents WHERE id = ?').run(req.params.id);
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
+98
View File
@@ -0,0 +1,98 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/emprunts.js — Passif patrimonial (Phase 3c, 28/09/26, demande Olivier). Objet séparé
* de comptes/enveloppes_referentiel : un emprunt est structurellement différent d'un actif (le
* capital restant dû diminue avec le temps, porte un taux et un échéancier fixés à la
* souscription) — cf. "Réflexion prospective" du plan de déploiement Enveloppes & Supports (doc
* projet), point 3. Mêmes conventions que comptes.js : scope par user_id, détenteur
* (investisseur_id) et établissement prêteur (institution_id) optionnels et nullables.
*/
const router = Router();
const Schema = z.object({
nom: z.string().min(1),
investisseur_id: z.number().int().positive().nullable().optional(),
institution_id: z.number().int().positive().nullable().optional(),
montant_emprunte: z.number().positive(),
capital_restant_du: z.number().nonnegative(),
taux_nominal: z.number().nullable().optional(),
date_debut: z.string().nullable().optional(),
duree_mois: z.number().int().positive().nullable().optional(),
mensualite: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT e.id, e.nom, e.investisseur_id, e.institution_id, e.montant_emprunte, e.capital_restant_du,
e.taux_nominal, e.date_debut, e.duree_mois, e.mensualite, e.notes, e.created_at,
e.regroupement_patrimonial_id, rp.nom AS regroupement_patrimonial_nom,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
FROM emprunts e
LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id
LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id
LEFT JOIN regroupements_patrimoniaux rp ON rp.id = e.regroupement_patrimonial_id
WHERE e.user_id = ?
ORDER BY e.nom
`).all(req.user.id);
res.json(rows);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
if (body.capital_restant_du > body.montant_emprunte) {
throw new HttpError(400, 'Le capital restant dû ne peut pas dépasser le montant emprunté');
}
const r = db.prepare(`
INSERT INTO emprunts (user_id, nom, investisseur_id, institution_id, montant_emprunte, capital_restant_du, taux_nominal, date_debut, duree_mois, mensualite, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(
req.user.id, body.nom, body.investisseur_id ?? null, body.institution_id ?? null,
body.montant_emprunte, body.capital_restant_du, body.taux_nominal ?? null,
body.date_debut || null, body.duree_mois ?? null, body.mensualite ?? null, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
if (body.capital_restant_du > body.montant_emprunte) {
throw new HttpError(400, 'Le capital restant dû ne peut pas dépasser le montant emprunté');
}
const r = db.prepare(`
UPDATE emprunts
SET nom=?, investisseur_id=?, institution_id=?, montant_emprunte=?, capital_restant_du=?,
taux_nominal=?, date_debut=?, duree_mois=?, mensualite=?, notes=?, updated_at=datetime('now')
WHERE id=? AND user_id=?
`).run(
body.nom, body.investisseur_id ?? null, body.institution_id ?? null,
body.montant_emprunte, body.capital_restant_du, body.taux_nominal ?? null,
body.date_debut || null, body.duree_mois ?? null, body.mensualite ?? null, body.notes || null,
req.params.id, req.user.id,
);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const r = db.prepare('DELETE FROM emprunts WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+194
View File
@@ -0,0 +1,194 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/enveloppesReferentiel.js — Référentiel admin des enveloppes d'épargne/investissement
* (Livret A, Assurance-vie, PEA, PER...) — chantier "Référentiel Enveloppes & Supports
* d'épargne" (28/09/26, demande Olivier).
*
* Même esprit que institutionsReferentiel.js : catalogue transverse à tous les espaces de
* travail, catégories (familles) en N-N via referentiel_categories_enveloppes. S'y ajoute une
* seconde relation N-N propre à ce référentiel : enveloppes_supports_disponibles, qui indique
* quels supports d'investissement (supports_referentiel — fonds euros, ETF, SCPI...) sont
* disponibles dans chaque enveloppe (ex. Assurance-vie multisupport ↔ fonds euros, ETF, SCPI...).
* Une enveloppe "cash" (Livret A, Compte courant...) n'a simplement aucune ligne dans cette
* seconde relation.
*
* Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion le 28/09/26) —
* aucun rattachement à l'existant (comptes, investissements...), en préparation du futur module
* patrimoine (cf. plan de déploiement, doc projet).
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
description: z.string().nullable().optional(),
plafond_eur: z.number().positive().nullable().optional(),
regime_fiscal: z.string().nullable().optional(),
eligible_compte: z.boolean().optional().default(false),
regroupement_patrimonial_id: z.number().int().positive().nullable().optional(),
categories_ids: z.array(z.number().int()).optional().default([]),
supports_ids: z.array(z.number().int()).optional().default([]),
});
// ── Helpers ────────────────────────────────────────────────────────────────
/** Catégories (familles) attachées à chaque enveloppe */
function attachCategories(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rc.enveloppe_id, c.id AS categorie_id, c.nom
FROM referentiel_categories_enveloppes rc
JOIN categories_enveloppes c ON c.id = rc.categorie_id
WHERE rc.enveloppe_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.enveloppe_id]) map[l.enveloppe_id] = [];
map[l.enveloppe_id].push({ id: l.categorie_id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
/** Supports disponibles attachés à chaque enveloppe */
function attachSupports(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT esd.enveloppe_id, s.id AS support_id, s.nom
FROM enveloppes_supports_disponibles esd
JOIN supports_referentiel s ON s.id = esd.support_id
WHERE esd.enveloppe_id IN (${ids.map(() => '?').join(',')})
ORDER BY s.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.enveloppe_id]) map[l.enveloppe_id] = [];
map[l.enveloppe_id].push({ id: l.support_id, nom: l.nom });
}
return rows.map(r => ({ ...r, supports: map[r.id] || [] }));
}
/** Regroupement patrimonial (+ parent éventuel) attaché à chaque enveloppe */
function attachRegroupement(rows) {
if (rows.length === 0) return rows;
const ids = [...new Set(rows.map(r => r.regroupement_patrimonial_id).filter(Boolean))];
if (ids.length === 0) return rows.map(r => ({ ...r, regroupement_patrimonial: null }));
const regs = db.prepare(`
SELECT r.id, r.nom, r.type, r.parent_id, p.nom AS parent_nom
FROM regroupements_patrimoniaux r
LEFT JOIN regroupements_patrimoniaux p ON p.id = r.parent_id
WHERE r.id IN (${ids.map(() => '?').join(',')})
`).all(...ids);
const map = Object.fromEntries(regs.map(r => [r.id, r]));
return rows.map(r => ({
...r,
regroupement_patrimonial: r.regroupement_patrimonial_id ? (map[r.regroupement_patrimonial_id] || null) : null,
}));
}
function saveCategories(enveloppeId, categoriesIds) {
db.prepare('DELETE FROM referentiel_categories_enveloppes WHERE enveloppe_id = ?').run(enveloppeId);
if (categoriesIds && categoriesIds.length > 0) {
const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?,?)');
for (const id of categoriesIds) ins.run(enveloppeId, id);
}
}
function saveSupports(enveloppeId, supportsIds) {
db.prepare('DELETE FROM enveloppes_supports_disponibles WHERE enveloppe_id = ?').run(enveloppeId);
if (supportsIds && supportsIds.length > 0) {
const ins = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?,?)');
for (const id of supportsIds) ins.run(enveloppeId, id);
}
}
// ── GET /api/enveloppes-referentiel ─────────────────────────────────────────
router.get('/', (_req, res) => {
let rows = db.prepare('SELECT * FROM enveloppes_referentiel ORDER BY nom').all();
rows = attachRegroupement(attachSupports(attachCategories(rows)));
res.json(rows);
});
// ── GET /api/enveloppes-referentiel/:id ─────────────────────────────────────
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Enveloppe introuvable');
const [enriched] = attachRegroupement(attachSupports(attachCategories([row])));
res.json(enriched);
} catch (e) { next(e); }
});
// ── POST /api/enveloppes-referentiel ────────────────────────────────────────
router.post('/', (req, res, next) => {
try {
const data = Schema.parse(req.body);
const existing = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?').get(data.nom);
if (existing) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
const r = db.prepare(`
INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, eligible_compte, regroupement_patrimonial_id, updated_at)
VALUES (?,?,?,?,?,?, datetime('now'))
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0, data.regroupement_patrimonial_id ?? null);
const enveloppeId = r.lastInsertRowid;
db.transaction(() => {
saveCategories(enveloppeId, data.categories_ids);
saveSupports(enveloppeId, data.supports_ids);
})();
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(enveloppeId);
const [enriched] = attachRegroupement(attachSupports(attachCategories([row])));
res.status(201).json(enriched);
} catch (e) { next(e); }
});
// ── PUT /api/enveloppes-referentiel/:id ─────────────────────────────────────
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Enveloppe introuvable');
const data = Schema.parse(req.body);
if (data.nom !== row.nom) {
const dup = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id);
if (dup) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
}
db.prepare(`
UPDATE enveloppes_referentiel
SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, eligible_compte=?, regroupement_patrimonial_id=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0, data.regroupement_patrimonial_id ?? null, row.id);
db.transaction(() => {
saveCategories(row.id, data.categories_ids);
saveSupports(row.id, data.supports_ids);
})();
const updated = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(row.id);
const [enriched] = attachSupports(attachCategories([updated]));
res.json(enriched);
} catch (e) { next(e); }
});
// ── DELETE /api/enveloppes-referentiel/:id ──────────────────────────────────
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM enveloppes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Enveloppe introuvable');
// referentiel_categories_enveloppes et enveloppes_supports_disponibles ont toutes deux
// ON DELETE CASCADE sur enveloppe_id : aucune purge manuelle nécessaire.
db.prepare('DELETE FROM enveloppes_referentiel WHERE id = ?').run(row.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
@@ -0,0 +1,52 @@
import { Router } from 'express';
import db from '../db/index.js';
/**
* routes/enveloppesReferentielPublic.js — lecture seule du référentiel "Enveloppes & Supports
* d'épargne" pour tous les utilisateurs authentifiés (pas requireAdmin), sur le modèle de
* institutionsReferentielPublic.js. Catalogue transverse : on expose ici une LISTE complète
* (avec catégories, pour regroupement par familles côté frontend), utilisée par le sélecteur
* "Enveloppe" de Paramètres > Mes comptes (Phase 3a, cf. plan de déploiement Enveloppes &
* Supports). Le champ `eligible_compte` permet au frontend de ne proposer, dans ce sélecteur,
* que le sous-ensemble "compte financier" — cadrage validé par Olivier via AskUserQuestion
* (28/09/26) — sans empêcher un futur écran (Phase 3b) de consommer la liste complète.
*/
const router = Router();
// Monté sous requireAuth (sans requireAdmin) — lecture seule.
function attachCategories(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rc.enveloppe_id, c.id, c.nom
FROM referentiel_categories_enveloppes rc
JOIN categories_enveloppes c ON c.id = rc.categorie_id
WHERE rc.enveloppe_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.enveloppe_id]) map[l.enveloppe_id] = [];
map[l.enveloppe_id].push({ id: l.id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
/* GET /api/enveloppes-referentiel-public — liste complète (lecture seule) */
router.get('/', (_req, res) => {
// regroupement_patrimonial_id ajouté (30/09/26, chantier "Visualisation d'un actif") : le
// frontend en a besoin pour filtrer les enveloppes du sélecteur de lignes_patrimoine à celles
// du regroupement d'origine (Immobilier, Autres actifs...), cf. LignePatrimoineDetail.jsx.
// description ajoutée (30/09/26, chantier "Compléter mon patrimoine" — modale d'ajout
// rapide) : le sous-choix d'actifs de la modale affiche icône + intitulé + courte description
// pour chaque enveloppe, sur le même patron que la fiche produit du référentiel admin.
const rows = db.prepare(`
SELECT id, nom, description, eligible_compte, regroupement_patrimonial_id
FROM enveloppes_referentiel
ORDER BY nom
`).all();
res.json(attachCategories(rows));
});
export default router;
+259
View File
@@ -0,0 +1,259 @@
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();
/**
* frais_operations — traçabilité des frais facturés par certaines plateformes
* (frais d'entrée, frais de gestion, frais sur distribution), qu'ils soient
* prélevés "à la source" (aucun mouvement de trésorerie réel — le montant
* est simplement documenté à titre de traçabilité) ou payés en "versement
* complémentaire" via le porte-monnaie de la plateforme ou un compte courant
* du détenteur (dans ce dernier cas, sans impact sur le solde du porte-monnaie).
*
* Montant stocké : TTC uniquement (pas de détail HT/TVA, non pertinent pour
* un particulier — cf. échange avec Olivier, 30/08/26).
*
* 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().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'),
compte_id: z.number().int().positive().nullable().optional(),
date_operation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
origine: z.enum(['souscription', 'remboursement', 'manuel']).default('manuel'),
// Classement visible côté utilisateur (31/08/26, demande Olivier) — distinct de `origine`
// (qui reste un détail technique de création). 'souscription' est réservée à l'étape
// "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(`
SELECT i.id, i.plateforme_id FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.id = ?
`).get(userId, invId);
if (!row) throw new HttpError(403, 'Investissement not in scope');
return row;
}
/** É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 = 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");
}
export function assertOwnedCompte(compteId, userId) {
if (!compteId) return;
const compte = db.prepare('SELECT id FROM comptes WHERE id = ? AND user_id = ?').get(compteId, userId);
if (!compte) throw new HttpError(403, 'Compte inconnu ou non autorisé');
}
/**
* 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(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 ${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 | ?investissement_pe_id=456
* Sans filtre : tous les frais du détenteur actif (header X-Investisseur-Id) pour le
* workspace ACTIF (résolu via resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending
* ou PE selon le type du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/
* investisseur_nom (vue portefeuille, ex. page dédiée Frais/FraisPe). ?scope=all agrège tous
* les détenteurs de l'utilisateur (vue "Famille et entreprises"), même mécanisme que GET
* /investissements-pe, /depots-retraits et /remboursements (19/09/26 — jusqu'ici cette route
* ignorait totalement le détenteur actif et renvoyait toujours tout le foyer, contrairement à
* ces trois autres routes ; bug remonté par Olivier : la page Frais ne filtrait jamais par
* détenteur, même un détenteur précis sélectionné). Le filtre par investissement_id/
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
* côté client, et reste volontairement family-wide (propriété déjà vérifiée par
* assertOwnedInvestissement(Pe) sur le deal précis demandé).
*/
router.get('/', (req, res, next) => {
try {
const { investissement_id, investissement_pe_id } = req.query;
let rows;
if (investissement_id) {
assertOwnedInvestissement(Number(investissement_id), req.user.id);
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom
FROM frais_operations f
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE f.investissement_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(Number(investissement_id));
} else if (investissement_pe_id) {
assertOwnedInvestissementPe(Number(investissement_pe_id), req);
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom
FROM frais_operations f
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(Number(investissement_pe_id));
} else {
const workspaceId = resolveActiveWorkspaceId(req);
const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId);
const scopeAll = req.query.scope === 'all';
let invCond, invArgs;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [req.user.id];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'inv.id = ?';
invArgs = [id];
}
if (!ws || ws.type === 'crowdlending') {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
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 ${invCond}
ORDER BY f.date_operation DESC, f.id DESC
`).all(...invArgs);
} else {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
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 ${invCond} AND ipe.workspace_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(...invArgs, workspaceId);
}
}
res.json(rows);
} catch (e) { next(e); }
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
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, body.categorie);
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
const r = db.prepare(`
INSERT INTO frais_operations
(investissement_id, investissement_pe_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(
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,
);
res.status(201).json({ id: r.lastInsertRowid, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const id = Number(req.params.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 = assertOwnedInvestissementEither(body, req);
assertRemboursementBelongs(body.remboursement_id, body);
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.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=?, 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 ?? 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,
);
res.json({ id, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const id = Number(req.params.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();
} catch (e) { next(e); }
});
export default router;
+54 -22
View File
@@ -23,53 +23,85 @@ function ensureRow() {
allow_unauth, app_name, app_url, allow_registration, min_password_length)
VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8)
`).run();
// documents_quota_bytes/documents_quota_count non listées ci-dessus : la
// colonne applique automatiquement son DEFAULT (5 Go / 500 documents,
// cf. migration dans db/index.js) quand elle est omise d'un INSERT.
}
// parametres_environnement / parametres_theme : normalement déjà créées avec
// leur ligne par défaut par la migration dans db/index.js (INSERT OR IGNORE)
// — filet de sécurité ici.
db.prepare('INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)').run();
db.prepare('INSERT OR IGNORE INTO parametres_theme (id) VALUES (1)').run();
}
// 1 Go = 1024^3 octets (convention déjà utilisée par fmtOctets côté frontend).
const GO = 1024 ** 3;
router.get('/', (_req, res, next) => {
try {
ensureRow();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length, documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get();
const envRow = db.prepare('SELECT environnement FROM parametres_environnement WHERE id = 1').get();
const themeRow = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get();
res.json({
appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '',
mcpUrl: row.mcp_url || '',
allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8,
appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '',
environnement: envRow?.environnement || 'developpement',
colorTheme: themeRow?.color_theme || 'classique',
mcpUrl: row.mcp_url || '',
allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8,
documentsQuotaGo: Math.round(((row.documents_quota_bytes || 5 * GO) / GO) * 100) / 100,
documentsQuotaCount: row.documents_quota_count || 500,
});
} catch (e) { next(e); }
});
const PatchSchema = z.object({
appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(),
mcpUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).optional(),
appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(),
environnement: z.enum(['production', 'qualification', 'developpement']).optional(),
colorTheme: z.enum(['classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne']).optional(),
mcpUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).optional(),
documentsQuotaGo: z.number().positive().max(1000).optional(),
documentsQuotaCount: z.number().int().positive().max(100000).optional(),
});
router.patch('/', (req, res, next) => {
try {
ensureRow();
const body = PatchSchema.parse(req.body);
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length, documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get();
db.prepare(`
UPDATE smtp_config SET
app_name = ?,
app_url = ?,
mcp_url = ?,
allow_registration = ?,
min_password_length = ?
app_name = ?,
app_url = ?,
mcp_url = ?,
allow_registration = ?,
min_password_length = ?,
documents_quota_bytes = ?,
documents_quota_count = ?
WHERE id = 1
`).run(
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
body.appUrl !== undefined ? body.appUrl : (row.app_url || ''),
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
body.appUrl !== undefined ? body.appUrl : (row.app_url || ''),
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
body.documentsQuotaGo !== undefined ? Math.round(body.documentsQuotaGo * GO) : (row.documents_quota_bytes || 5 * GO),
body.documentsQuotaCount !== undefined ? body.documentsQuotaCount : (row.documents_quota_count || 500),
);
if (body.environnement !== undefined) {
db.prepare('UPDATE parametres_environnement SET environnement = ? WHERE id = 1').run(body.environnement);
}
if (body.colorTheme !== undefined) {
db.prepare('UPDATE parametres_theme SET color_theme = ? WHERE id = 1').run(body.colorTheme);
}
res.json({ ok: true });
} catch (e) { next(e); }
});
+4 -1
View File
@@ -8,7 +8,10 @@ import { requireAdmin } from '../middleware/auth.js';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const iconsDir = path.resolve(__dirname, '../../../data/icons');
const dataDir = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: path.resolve(__dirname, '../../../data'); // fallback dev : 3 niveaux au-dessus de routes/
const iconsDir = path.join(dataDir, 'icons');
const historyDir = path.resolve(iconsDir, 'history');
fs.mkdirSync(iconsDir, { recursive: true });
fs.mkdirSync(historyDir, { recursive: true });
+245 -26
View File
@@ -9,10 +9,13 @@ 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';
import { syncInvestissementStatut } from './remboursements.js';
import { readZip } from '../utils/zip.js';
import { docsDir, getUsage, getQuotaConfig, MAX_FILE_BYTES } from './documents.js';
const router = Router();
@@ -24,6 +27,15 @@ const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
});
// Instance dédiée à l'import de dossier d'investissement (zip manifest.json +
// documents, cf. GET /api/investissements/:id/export) : limite plus haute que
// les imports xlsx/csv classiques car le zip embarque aussi les fichiers du
// dossier, pas seulement des lignes tabulaires.
const dossierUpload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
});
/**
* Step 1: POST /api/imports/preview
* multipart/form-data with `file` (xlsx/csv)
@@ -348,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++) {
@@ -383,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,
@@ -394,6 +409,7 @@ router.post('/apply', (req, res, next) => {
v('libelle') || null,
v('reference') || null,
srcLabel,
workspaceId,
);
} else if (module === 'investissements') {
@@ -457,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);
@@ -626,26 +643,93 @@ router.post('/template', (req, res, next) => {
});
/**
* POST /api/imports/dossier
* Importe un dossier investissement complet (format d'export natif).
* Scénario CREATE : le dossier n'existe pas → création complète.
* Scénario UPDATE : le dossier existe déjà → mise à jour des champs + remboursements manquants.
* Identification : (investisseur_id, nom_projet, date_souscription) — clé naturelle portable.
* Lit et valide un zip de dossier d'investissement (manifest.json + documents/,
* cf. GET /api/investissements/:id/export). Partagé par /dossier/preview et
* /dossier/apply pour ne parser le zip qu'à un seul endroit.
*/
router.post('/dossier', (req, res, next) => {
function parseDossierZip(zipPath) {
const buffer = fs.readFileSync(zipPath);
let zipEntries;
try {
zipEntries = readZip(buffer);
} catch {
throw new HttpError(400, 'Fichier zip invalide ou corrompu');
}
const manifestEntry = zipEntries.find(e => e.name === 'manifest.json');
if (!manifestEntry) throw new HttpError(400, 'Zip invalide — manifest.json introuvable');
let manifest;
try {
manifest = JSON.parse(manifestEntry.data.toString('utf8'));
} catch {
throw new HttpError(400, 'manifest.json illisible (JSON invalide)');
}
if (!manifest || manifest.type !== 'dossier_investissement') {
throw new HttpError(400, 'Format invalide — attendu un zip dont le manifest.json est de type "dossier_investissement"');
}
return { manifest, zipEntries };
}
/**
* POST /api/imports/dossier/preview
* Étape 1 : dépose le zip exporté depuis la fiche investissement (bouton
* « Exporter » du bloc Informations du projet), le valide et renvoie son
* contenu pour aperçu avant confirmation. Le fichier reste sur disque
* (tempId) jusqu'à l'appel de /dossier/apply — même principe que le couple
* /preview + /apply utilisé pour les imports xlsx/csv plus haut dans ce fichier.
*/
router.post('/dossier/preview', dossierUpload.single('file'), (req, res, next) => {
try {
if (!req.file) throw new HttpError(400, 'Fichier zip requis');
const { manifest } = parseDossierZip(req.file.path);
res.json({ tempId: path.basename(req.file.path), manifest });
} catch (e) { next(e); }
});
/**
* POST /api/imports/dossier/apply
* Étape 2 : importe pour de bon le dossier déposé à l'étape /preview (tempId).
* Scénario CREATE : le dossier n'existe pas → création complète.
* Scénario UPDATE : le dossier existe déjà → mise à jour des champs + ajout des
* remboursements / réinvestissements / catégories / secteurs / révisions /
* pertes / documents manquants — la déduplication à chaque étape garantit
* qu'un réimport répété du même zip ne crée jamais de doublons.
* Identification du dossier : (investisseur_id, nom_projet, date_souscription)
* — clé naturelle portable. L'investisseur est celui actuellement sélectionné
* dans l'application (X-Investisseur-Id), pas celui décrit dans le manifeste
* (cf. décision Olivier : le rattachement suit le contexte courant de
* l'utilisateur, pas les données du fichier importé).
* Les projections (simulation) du manifeste ne sont pas réimportées : la
* simulation est régénérée après coup à partir des données réelles, comme
* pour toute création/modification d'investissement.
*/
router.post('/dossier/apply', (req, res, next) => {
const writtenFiles = []; // fichiers documents déjà écrits sur disque pendant cette requête — nettoyés si erreur ensuite
try {
requireInvestisseur(req, res, () => {});
const { dossier } = req.body || {};
if (!dossier || dossier.type !== 'dossier_investissement') {
throw new HttpError(400, 'Format invalide — attendu { dossier: { type: "dossier_investissement", ... } }');
}
const { investissement: inv, plateforme: platInfo, remboursements = [], reinvestissements = [], historique = [] } = dossier;
const { tempId } = req.body || {};
if (!tempId) throw new HttpError(400, 'tempId est requis');
const tempPath = path.join(UPLOAD_DIR, tempId);
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Fichier déposé expiré — redéposez le zip');
const { manifest: dossier, zipEntries } = parseDossierZip(tempPath);
const {
investissement: inv, plateforme: platInfo,
remboursements = [], reinvestissements = [], historique = [],
categories_inv: categoriesNoms = [], secteurs_inv: secteursNoms = [],
revisions = [], pertes = [], documents: documentsMeta = [],
} = dossier;
if (!inv?.nom_projet || !inv?.date_souscription) {
throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription');
}
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 ─────────────────── */
@@ -672,8 +756,9 @@ router.post('/dossier', (req, res, next) => {
INSERT INTO investissements
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
date_premiere_echeance, date_cible, date_debut_simul, montant_investi,
taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes,
pays_exposition, methode_remboursement, echeance_fin_de_mois, nom_compte_courant)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?, ?,?,?,?)
`).run(
req.investisseur.id, plateformeId,
inv.nom_projet, inv.emetteur || null,
@@ -682,6 +767,8 @@ router.post('/dossier', (req, res, next) => {
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
inv.statut || 'en_cours', inv.reference || null, inv.notes || null,
inv.pays_exposition || 'FR', inv.methode_remboursement || null,
inv.echeance_fin_de_mois ? 1 : 0, inv.nom_compte_courant || null,
);
investissementId = Number(r.lastInsertRowid);
@@ -690,14 +777,15 @@ router.post('/dossier', (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,
);
}
@@ -763,7 +851,8 @@ router.post('/dossier', (req, res, next) => {
date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?,
montant_investi = ?, taux_interet = ?, duree_mois = ?,
type_remb = ?, freq_interets = ?, statut = ?,
reference = ?, notes = ?
reference = ?, notes = ?,
pays_exposition = ?, methode_remboursement = ?, echeance_fin_de_mois = ?, nom_compte_courant = ?
WHERE id = ?
`).run(
nouveau.plateforme_id, inv.emetteur || null,
@@ -771,6 +860,8 @@ router.post('/dossier', (req, res, next) => {
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
inv.reference || null, inv.notes || null,
inv.pays_exposition || 'FR', inv.methode_remboursement || null,
inv.echeance_fin_de_mois ? 1 : 0, inv.nom_compte_courant || null,
investissementId,
);
@@ -786,14 +877,15 @@ router.post('/dossier', (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++;
}
@@ -848,11 +940,130 @@ router.post('/dossier', (req, res, next) => {
action = 'updated';
}
/* ── Catégories / secteurs d'investissement (résolution par nom,
trouve-ou-crée ; association idempotente via INSERT OR IGNORE sur
la table de jonction — aucun doublon possible même en cas de
réimport répété du même zip) ─────────────────────────────── */
const findOrCreateCategorieInv = (nom) => {
let row = db.prepare('SELECT id FROM categories_inv WHERE nom = ?').get(nom);
if (!row) {
const r = db.prepare('INSERT INTO categories_inv (nom) VALUES (?)').run(nom);
row = { id: r.lastInsertRowid };
}
return row.id;
};
const findOrCreateSecteurInv = (nom) => {
let row = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ?').get(nom);
if (!row) {
const r = db.prepare('INSERT INTO secteurs_inv (nom) VALUES (?)').run(nom);
row = { id: r.lastInsertRowid };
}
return row.id;
};
const insCatInv = db.prepare('INSERT OR IGNORE INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)');
const insSectInv = db.prepare('INSERT OR IGNORE INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)');
for (const nom of categoriesNoms) if (nom) insCatInv.run(investissementId, findOrCreateCategorieInv(nom));
for (const nom of secteursNoms) if (nom) insSectInv.run(investissementId, findOrCreateSecteurInv(nom));
/* ── Révisions : ajouter les manquantes (clé = date_effet + motif) ── */
for (const rv of revisions) {
if (!rv.date_effet || !rv.motif) continue;
const exists = db.prepare(
'SELECT id FROM investissement_revisions WHERE investissement_id = ? AND date_effet = ? AND motif = ?'
).get(investissementId, rv.date_effet, rv.motif);
if (!exists) {
db.prepare(`
INSERT INTO investissement_revisions
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible, motif)
VALUES (?,?,?,?,?,?,?)
`).run(
investissementId, rv.date_effet,
rv.ancien_taux ?? null, rv.nouveau_taux ?? null,
rv.ancienne_date_cible ?? null, rv.nouvelle_date_cible ?? null,
rv.motif,
);
}
}
/* ── Pertes : ajouter les manquantes (clé = date_effet + motif) ──── */
for (const p of pertes) {
if (!p.date_effet || !p.motif || p.montant_perte == null) continue;
const exists = db.prepare(
'SELECT id FROM investissement_pertes WHERE investissement_id = ? AND date_effet = ? AND motif = ?'
).get(investissementId, p.date_effet, p.motif);
if (!exists) {
db.prepare(`
INSERT INTO investissement_pertes (investissement_id, date_effet, montant_perte, ancien_statut, motif)
VALUES (?,?,?,?,?)
`).run(investissementId, p.date_effet, p.montant_perte, p.ancien_statut ?? null, p.motif);
}
}
/* ── Documents : recrée les fichiers absents. Déduplication par
(entity, nom_original, taille_octets) : un réimport répété du
même zip ne recrée jamais un document déjà présent. ────────── */
const docsToInsert = [];
for (const docMeta of documentsMeta) {
const already = db.prepare(`
SELECT id FROM documents
WHERE user_id = ? AND entity_type = 'investissement' AND entity_id = ?
AND nom_original = ? AND taille_octets = ?
`).get(req.user.id, investissementId, docMeta.nom_original || null, docMeta.taille_octets ?? null);
if (already) { docsSkipped++; continue; }
const zipEntry = zipEntries.find(e => e.name === docMeta.zip_path);
if (!zipEntry) continue; // référencé dans le manifeste mais absent du zip — ignoré silencieusement
// Même plafond par fichier que l'upload normal d'un document — un
// zip de dossier ne doit pas permettre de contourner cette limite.
if (zipEntry.data.length > MAX_FILE_BYTES) {
throw new HttpError(400, `Le document « ${docMeta.nom_original || docMeta.zip_path} » dépasse la taille maximale autorisée par fichier (${Math.round(MAX_FILE_BYTES / 1024 / 1024)} Mo).`);
}
docsToInsert.push({ docMeta, zipEntry });
}
// Vérification du quota AVANT d'écrire le moindre fichier sur disque —
// même garde-fou que handleUpload() dans documents.js pour l'upload
// normal d'un document, jusqu'ici absent de l'import de dossier
// (un dossier importé pouvait donc dépasser le quota configuré).
// Vérifié en une seule fois pour tout le lot de documents à insérer.
if (docsToInsert.length > 0) {
const usage = getUsage(req.user.id);
const { quotaBytes, quotaCount } = getQuotaConfig();
const addedBytes = docsToInsert.reduce((sum, { zipEntry }) => sum + zipEntry.data.length, 0);
if (usage.used_count + docsToInsert.length > quotaCount) {
throw new HttpError(400, `Quota de documents atteint (${quotaCount} maximum) — ce dossier ajouterait ${docsToInsert.length} document(s), supprimez des documents existants avant de réessayer.`);
}
if (usage.used_bytes + addedBytes > quotaBytes) {
throw new HttpError(400, `Quota de stockage dépassé (${(quotaBytes / 1024 / 1024 / 1024).toFixed(0)} Go maximum) — ce dossier ajouterait ${(addedBytes / 1024 / 1024).toFixed(1)} Mo, supprimez des documents existants avant de réessayer.`);
}
}
for (const { docMeta, zipEntry } of docsToInsert) {
const ext = (docMeta.extension || '').toLowerCase();
const diskName = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext ? '.' + ext : ''}`;
const diskPath = path.join(docsDir, diskName);
fs.writeFileSync(diskPath, zipEntry.data);
writtenFiles.push(diskPath);
db.prepare(`
INSERT INTO documents (user_id, categorie, entity_type, entity_id, nom_affichage, nom_original, extension, mime_type, taille_octets, filename)
VALUES (?,?,?,?,?,?,?,?,?,?)
`).run(
req.user.id, docMeta.categorie || 'investissement', 'investissement', investissementId,
docMeta.nom_affichage || docMeta.nom_original || diskName,
docMeta.nom_original || docMeta.nom_affichage || diskName,
ext, docMeta.mime_type || null,
docMeta.taille_octets ?? zipEntry.data.length,
diskName,
);
docsInserted++;
}
});
tx();
/* ── 3. Régénérer la simulation ─────────────────────────── */
const fresh = db.prepare('SELECT * FROM investissements WHERE id = ?').get(investissementId);
generateSimulWithReinvestissements(db, investissementId);
/* ── 4. Log import ──────────────────────────────────────── */
@@ -864,11 +1075,19 @@ router.post('/dossier', (req, res, next) => {
'dossier_investissement',
`Dossier_${inv.nom_projet}`,
1, 1, 0,
JSON.stringify({ action, investissementId }),
JSON.stringify({ action, investissementId, docsInserted, docsSkipped }),
);
res.json({ action, investissementId });
} catch (e) { next(e); }
try { fs.unlinkSync(tempPath); } catch { /* déjà absent */ }
res.json({ action, investissementId, docsInserted, docsSkipped });
} catch (e) {
// Une erreur après écriture de documents sur disque n'annule pas ces
// écritures (seule la transaction DB est annulée automatiquement par
// better-sqlite3) : on nettoie pour ne pas laisser de fichiers orphelins.
for (const f of writtenFiles) { try { fs.unlinkSync(f); } catch { /* déjà absent */ } }
next(e);
}
});
router.get('/history', (req, res) => {
@@ -0,0 +1,393 @@
import { Router } from 'express';
import { z } from 'zod';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { getDefaultActiveProvider } from '../ai/providerConfig.js';
import { decryptSecret } from '../ai/crypto.js';
import { lookupInstitution } from '../ai/providers/anthropic.js';
import { audit } from '../utils/audit.js';
/**
* routes/institutionsReferentiel.js — Référentiel admin des institutions financières
* (banques, courtiers, assureurs...) — chantier "Référentiel Institutions financières"
* (26/09/26, demande Olivier).
*
* Phase 1 uniquement (cf. plan de déploiement, doc projet) : catalogue admin (logo, icône,
* catégories) — même patron que routes/referentiel.js (plateformes_referentiel), mais SANS les
* champs spécifiques aux plateformes d'investissement (fiscalité, produit fiscal, frais,
* méthode de remboursement...) et SANS rattachement workspace : une institution financière est
* transverse à tous les espaces de travail (décision d'Olivier, AskUserQuestion 26/09/26).
* Aucun consommateur applicatif pour l'instant (Phase 2, plus tard : comptes.banque) — donc pas
* de mécanique de "poussée" vers des enregistrements liés, contrairement à referentiel.js.
*
* Le dossier de stockage des images est partagé avec le référentiel des plateformes
* (<DATA_DIR>/logos/) plutôt qu'un sous-dossier dédié — même volumétrie modeste, noms de
* fichiers déjà uniques (préfixe inst_ + id + timestamp) donc aucun risque de collision.
*/
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: path.resolve(__dirname, '../../../data'); // fallback dev : 3 niveaux au-dessus de routes/
const logosDir = path.join(dataDir, 'logos');
fs.mkdirSync(logosDir, { recursive: true });
// ── Traitement image (dupliqué depuis referentiel.js/plateformes.js — même précédent
// déjà en place dans ce fichier : "réutilisé depuis plateformes.js" y est aussi une
// duplication volontaire plutôt qu'un module partagé) ──────────────────────────────
async function processLogoFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.svg') {
try {
let svg = fs.readFileSync(filePath, 'utf8');
svg = svg.replace(/(<svg\b[^>]*)\sstyle="([^"]*)"/i, (_, tag, style) => {
const cleaned = style.split(';').filter(s => !/^\s*background(-color)?\s*:/i.test(s)).join(';').replace(/^;+|;+$/g, '');
return cleaned ? `${tag} style="${cleaned}"` : tag;
});
svg = svg.replace(/\s+enable-background="[^"]*"/gi, '');
fs.writeFileSync(filePath, svg, 'utf8');
} catch (e) { console.warn('[inst-logos] SVG cleanup failed:', e.message); }
return filePath;
}
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
const pngPath = filePath.replace(/\.(jpg|jpeg|webp|png)$/i, '.png');
try {
const { data, info } = await sharp(filePath).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
for (let i = 0; i < data.length; i += 4) {
if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) data[i + 3] = 0;
}
await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
.png({ compressionLevel: 8 }).toFile(pngPath);
if (pngPath !== filePath) fs.unlinkSync(filePath);
return pngPath;
} catch (e) { console.warn('[inst-logos] sharp failed:', e.message); return filePath; }
}
return filePath;
}
// ── Multer ──────────────────────────────────────────────────────────────────
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, logosDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.png';
cb(null, `inst_${req.params.id}_${Date.now()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp'];
if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true);
else cb(new Error('Format non supporté — SVG, PNG, JPG ou WebP uniquement'));
},
});
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
url: z.string().url().optional().or(z.literal('')).nullable(),
domiciliation: z.string().min(1).max(10).default('FR'),
description: z.string().nullable().optional(),
categories_ids: z.array(z.number().int()).optional().default([]),
});
// ── Helpers ────────────────────────────────────────────────────────────────
/** Catégories (type d'établissement) attachées à chaque entrée */
function attachCategories(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rc.institution_id, c.id AS categorie_id, c.nom
FROM referentiel_categories_institutions rc
JOIN categories_institutions c ON c.id = rc.categorie_id
WHERE rc.institution_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.institution_id]) map[l.institution_id] = [];
map[l.institution_id].push({ id: l.categorie_id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
function saveCategories(institutionId, categoriesIds) {
db.prepare('DELETE FROM referentiel_categories_institutions WHERE institution_id = ?').run(institutionId);
if (categoriesIds && categoriesIds.length > 0) {
const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?,?)');
for (const id of categoriesIds) ins.run(institutionId, id);
}
}
// ── GET /api/institutions-referentiel ───────────────────────────────────────
router.get('/', (_req, res) => {
let rows = db.prepare('SELECT * FROM institutions_referentiel ORDER BY nom').all();
rows = attachCategories(rows);
res.json(rows);
});
// ── GET /api/institutions-referentiel/:id ───────────────────────────────────
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Institution introuvable');
const [enriched] = attachCategories([row]);
res.json(enriched);
} catch (e) { next(e); }
});
// ── POST /api/institutions-referentiel ──────────────────────────────────────
router.post('/', (req, res, next) => {
try {
const data = Schema.parse(req.body);
const existing = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?').get(data.nom);
if (existing) throw new HttpError(409, `Une institution "${data.nom}" existe déjà`);
const r = db.prepare(`
INSERT INTO institutions_referentiel (nom, url, domiciliation, description, updated_at)
VALUES (?,?,?,?, datetime('now'))
`).run(data.nom, data.url || null, data.domiciliation, data.description || null);
const institutionId = r.lastInsertRowid;
db.transaction(() => saveCategories(institutionId, data.categories_ids))();
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institutionId);
const [enriched] = attachCategories([row]);
res.status(201).json(enriched);
} catch (e) { next(e); }
});
// ── PUT /api/institutions-referentiel/:id ───────────────────────────────────
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Institution introuvable');
const data = Schema.parse(req.body);
if (data.nom !== row.nom) {
const dup = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id);
if (dup) throw new HttpError(409, `Une institution "${data.nom}" existe déjà`);
}
db.prepare(`
UPDATE institutions_referentiel
SET nom=?, url=?, domiciliation=?, description=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.url || null, data.domiciliation, data.description || null, row.id);
db.transaction(() => saveCategories(row.id, data.categories_ids))();
const updated = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(row.id);
const [enriched] = attachCategories([updated]);
res.json(enriched);
} catch (e) { next(e); }
});
// ── DELETE /api/institutions-referentiel/:id ────────────────────────────────
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Institution introuvable');
for (const fname of [row.logo_filename, row.icone_filename]) {
if (!fname) continue;
const fpath = path.join(logosDir, fname);
if (fs.existsSync(fpath)) fs.unlink(fpath, () => {});
}
db.prepare('DELETE FROM institutions_referentiel WHERE id = ?').run(row.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
// ── POST /api/institutions-referentiel/:id/logo ─────────────────────────────
router.post('/:id/logo', upload.single('file'), async (req, res, next) => {
try {
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
if (!ref) throw new HttpError(404, 'Institution introuvable');
if (!req.file) throw new HttpError(400, 'Fichier requis');
const finalPath = await processLogoFile(req.file.path);
const filename = path.basename(finalPath);
if (ref.logo_filename) {
const old = path.join(logosDir, ref.logo_filename);
if (fs.existsSync(old)) fs.unlink(old, () => {});
}
db.prepare(`UPDATE institutions_referentiel SET logo_filename=?, updated_at=datetime('now') WHERE id=?`)
.run(filename, ref.id);
res.json({ logo_filename: filename });
} catch (e) { next(e); }
});
// ── DELETE /api/institutions-referentiel/:id/logo ───────────────────────────
router.delete('/:id/logo', (req, res, next) => {
try {
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
if (!ref) throw new HttpError(404, 'Institution introuvable');
if (ref.logo_filename) {
const filePath = path.join(logosDir, ref.logo_filename);
if (fs.existsSync(filePath)) fs.unlink(filePath, () => {});
}
db.prepare(`UPDATE institutions_referentiel SET logo_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
// ── POST /api/institutions-referentiel/:id/icone ────────────────────────────
router.post('/:id/icone', upload.single('file'), async (req, res, next) => {
try {
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
if (!ref) throw new HttpError(404, 'Institution introuvable');
if (!req.file) throw new HttpError(400, 'Fichier requis');
const finalPath = await processLogoFile(req.file.path);
const filename = path.basename(finalPath);
if (ref.icone_filename) {
const oldFile = path.join(logosDir, ref.icone_filename);
if (fs.existsSync(oldFile)) fs.unlink(oldFile, () => {});
}
db.prepare(`UPDATE institutions_referentiel SET icone_filename=?, updated_at=datetime('now') WHERE id=?`)
.run(filename, ref.id);
res.json({ icone_filename: filename });
} catch (e) { next(e); }
});
// ── DELETE /api/institutions-referentiel/:id/icone ──────────────────────────
router.delete('/:id/icone', (req, res, next) => {
try {
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id);
if (!ref) throw new HttpError(404, 'Institution introuvable');
if (ref.icone_filename) {
const filePath = path.join(logosDir, ref.icone_filename);
if (fs.existsSync(filePath)) fs.unlink(filePath, () => {});
}
db.prepare(`UPDATE institutions_referentiel SET icone_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
// ── POST /api/institutions-referentiel/ai-lookup ────────────────────────────
// Icône "baguette magique" d'InstitutionModal (demande Olivier 27/09/26) : recherche
// web automatique via le connecteur IA (Admin > Intelligence artificielle), pour
// pré-remplir au minimum description + domiciliation depuis le nom (et l'URL si
// déjà saisie) tapés dans le formulaire — fonctionne aussi bien en création (avant
// tout enregistrement) qu'en édition. Réponse toujours 200 : { ok:false, error }
// n'est pas une erreur HTTP mais un échec applicatif (IA désactivée, aucun
// fournisseur actif, type de fournisseur non pris en charge...) — même précédent
// que POST /providers/:id/test dans adminAi.js.
const AiLookupSchema = z.object({
nom: z.string().min(1).max(200),
url: z.string().url().optional().or(z.literal('')).nullable(),
});
router.post('/ai-lookup', async (req, res, next) => {
try {
const { nom, url } = AiLookupSchema.parse(req.body);
const config = db.prepare('SELECT actif FROM ai_config WHERE id = 1').get();
if (!config?.actif) {
return res.json({ ok: false, error: "L'Assistant IA n'est pas activé (Admin > Intelligence artificielle)." });
}
const provider = getDefaultActiveProvider();
if (!provider) {
return res.json({ ok: false, error: "Aucun fournisseur IA actif n'est configuré." });
}
// Seul le fournisseur "anthropic" (natif) expose l'outil de recherche web
// serveur utilisé par lookupInstitution() — un fournisseur openai_compatible
// n'a aucune garantie d'accès web réel selon le modèle/service derrière
// base_url, on préfère donc refuser explicitement plutôt que de renvoyer une
// réponse non vérifiée présentée comme une recherche internet.
if (provider.type !== 'anthropic') {
return res.json({
ok: false,
error: `La recherche web automatique n'est prise en charge que pour un fournisseur Anthropic (natif) — le fournisseur actif ("${provider.nom}") est de type "${provider.type}".`,
});
}
let apiKey;
try {
apiKey = decryptSecret(provider.api_key_chiffree);
} catch (e) {
throw new HttpError(500, `Impossible de déchiffrer la clé API : ${e.message}`);
}
// Journalisation dans ai_usage_log (même table que le chat utilisateur, routes/ai.js) —
// demandé par Olivier le 27/09/26 : ces appels consomment de vrais tokens (recherche web
// incluse) et doivent remonter dans Admin > Assistant IA > Usage, pas seulement dans le
// journal d'audit. investisseur_id = null (route admin, pas de contexte investisseur) —
// best-effort comme dans routes/ai.js : un échec de log ne doit jamais faire échouer la
// réponse à l'utilisateur.
function logUsage({ usage, toolCalled, erreur }) {
try {
db.prepare(`
INSERT INTO ai_usage_log
(user_id, investisseur_id, provider_id, modele, tokens_entree, tokens_sortie, outils_appeles, erreur)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user.id,
null,
provider.id,
provider.modele,
usage?.input_tokens ?? null,
usage?.output_tokens ?? null,
toolCalled ? JSON.stringify(['web_search']) : null,
erreur || null,
);
} catch (logErr) {
console.error("[institutions-referentiel/ai-lookup] échec du log d'usage (ignoré) :", logErr.message);
}
}
// Liste fermée des catégories existantes — transmise au modèle pour qu'il choisisse
// EXACTEMENT parmi des libellés déjà connus du référentiel (voir lookupInstitution()),
// remappables en id de façon fiable plutôt que du texte libre à recréer/dédupliquer.
const categories = db.prepare('SELECT id, nom FROM categories_institutions ORDER BY nom').all();
try {
const result = await lookupInstitution({ apiKey, model: provider.modele, nom, url: url || undefined, categories });
logUsage({ usage: result.usage, toolCalled: result.toolCalled, erreur: null });
audit(req, {
action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id,
details: { nom, ok: true, dureeMs: result.durationMs, sourcesCount: result.sourcesCount, categoriesCount: result.categoryIds.length },
});
res.json({
ok: true,
description: result.description,
domiciliation: result.domiciliation,
url: result.url,
categories_ids: result.categoryIds,
dureeMs: result.durationMs,
});
} catch (e) {
logUsage({ usage: e.usage, toolCalled: e.toolCalled, erreur: e.message });
audit(req, {
action: 'institution_ai_lookup', category: 'ai', actorId: req.user.id,
details: { nom, ok: false, error: e.message },
});
res.json({ ok: false, error: e.message });
}
} catch (e) { next(e); }
});
export default router;
@@ -0,0 +1,44 @@
import { Router } from 'express';
import db from '../db/index.js';
/**
* routes/institutionsReferentielPublic.js — lecture seule du référentiel "Institutions
* financières" pour tous les utilisateurs authentifiés (pas requireAdmin), sur le modèle de
* referentielPublic.js. Contrairement aux plateformes (où chaque utilisateur crée sa propre
* entrée, réconciliée a posteriori par l'admin via OrphelinesSection), le référentiel
* institutions est un catalogue transverse : on expose ici une LISTE complète, utilisée par le
* sélecteur "Banque" de Paramètres > Mes comptes (Phase 2, cf. plan de déploiement).
*/
const router = Router();
// Monté sous requireAuth (sans requireAdmin) — lecture seule.
function attachCategories(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rc.institution_id, c.id, c.nom
FROM referentiel_categories_institutions rc
JOIN categories_institutions c ON c.id = rc.categorie_id
WHERE rc.institution_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.institution_id]) map[l.institution_id] = [];
map[l.institution_id].push({ id: l.id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
/* GET /api/institutions-referentiel-public — liste complète (lecture seule) */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT id, nom, domiciliation, logo_filename, icone_filename
FROM institutions_referentiel
ORDER BY nom
`).all();
res.json(attachCategories(rows));
});
export default router;
+224 -27
View File
@@ -1,10 +1,14 @@
import { Router } from 'express';
import { z } from 'zod';
import path from 'node:path';
import fs from 'node:fs';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimul, generateSimulWithReinvestissements, monthsDiff, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js';
import { checkStatutsRetard } from '../jobs/autoStatut.js';
import { createZip, sanitizeZipPart } from '../utils/zip.js';
import { docsDir, deleteDocumentsForEntity } from './documents.js';
const router = Router();
@@ -221,7 +225,7 @@ router.get('/comptes-par-investisseur/:investisseur_id', (req, res, next) => {
.get(invId, req.user.id);
if (!inv) throw new HttpError(403, 'Investisseur non autorisé');
const rows = db.prepare(
'SELECT id, nom, type, banque FROM comptes WHERE investisseur_id = ? AND user_id = ? ORDER BY type, nom'
'SELECT id, nom, banque, compte_reglement_par_defaut FROM comptes WHERE investisseur_id = ? AND user_id = ? ORDER BY compte_reglement_par_defaut DESC, nom'
).all(invId, req.user.id);
res.json(rows);
} catch (e) { next(e); }
@@ -260,6 +264,10 @@ router.post('/fix-differe-dates', (req, res, next) => {
WHERE i.type_remb = 'differe'
AND i.date_souscription IS NOT NULL
AND i.duree_mois IS NOT NULL
-- Bug corrigé le 26/09/26 (dossier "Le Mitra") : un prêt déjà remboursé/clôturé n'a
-- plus lieu d'avoir sa date d'échéance "corrigée" — cf. commentaire plus bas sur
-- regenererEcheancier() pour le détail du bug que cette exclusion referme.
AND i.statut NOT IN ('rembourse', 'cloture')
`).all(req.user.id);
// Approximation jours/mois (moyenne julienne) — cohérent avec l'ancien seuil fixe
@@ -323,20 +331,20 @@ router.post('/fix-differe-dates', (req, res, next) => {
`écart > ${seuilMois} mois avec date_souscription + duree_mois`,
});
// Régénère l'échéancier de projection avec la date corrigée — sans ça,
// simul_remboursements reste calé sur l'ancienne date aberrante.
generateSimul(db, {
id: inv.id,
montant_investi: inv.montant_investi,
taux_interet: inv.taux_interet,
duree_mois: inv.duree_mois,
type_remb: inv.type_remb,
freq_interets: inv.freq_interets,
date_premiere_echeance: dateCalculee,
date_debut_simul: inv.date_debut_simul,
date_souscription: inv.date_souscription,
echeance_fin_de_mois: inv.echeance_fin_de_mois ?? 0,
});
// Régénère l'échéancier de projection avec la date corrigée, PUIS réconcilie contre
// les remboursements réels déjà enregistrés. Bug corrigé le 26/09/26 (dossier "Le
// Mitra", statut déjà "Remboursé" depuis le 24/07/2025) : un generateSimul() direct
// ignore tout remboursement déjà perçu et recrée toujours une échéance naïve à la
// date corrigée — y compris pour un prêt intégralement soldé, dont la date corrigée
// (date_souscription + duree_mois) tombait dans le futur. L'échéance orpheline
// réapparaissait alors comme "à recevoir" dans le Dashboard et sur la fiche du prêt.
// regenererEcheancier() applique le même correctif que POST/PUT /revisions (cf.
// project_revision_correction) : pour un prêt différé (jamais de restructuration,
// donc jamais de date_debut_simul), il rejoue l'historique des remboursements de
// capital via replayAdjustSimulForActuals() après régénération, qui supprime toute
// échéance future désormais caduque. Filet de sécurité en plus de l'exclusion des
// prêts clôturés/remboursés ajoutée ci-dessus (WHERE i.statut NOT IN (...)).
regenererEcheancier(inv.id);
corriges.push({
id: inv.id,
@@ -372,7 +380,7 @@ router.post('/fix-differe-dates', (req, res, next) => {
router.post('/check-echeancier-differe', (req, res, next) => {
try {
const rows = db.prepare(`
SELECT i.id, i.nom_projet, i.date_premiere_echeance, i.date_cible,
SELECT i.id, i.nom_projet, i.statut, i.date_premiere_echeance, i.date_cible,
i.montant_investi, i.taux_interet, i.duree_mois, i.type_remb, i.freq_interets,
i.date_debut_simul, i.date_souscription, i.echeance_fin_de_mois
FROM investissements i
@@ -389,15 +397,23 @@ router.post('/check-echeancier-differe', (req, res, next) => {
WHERE investissement_id = ?
ORDER BY numero_echeance
`);
const hasReinvest = db.prepare('SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1');
const corriges = [];
for (const inv of rows) {
const simulRows = getSimul.all(inv.id);
const incoherent =
simulRows.length !== 1 ||
simulRows[0].date_prevue !== inv.date_premiere_echeance;
// Bug corrigé le 26/09/26 (dossier "Le Mitra") : un prêt déjà remboursé ou clôturé ne
// doit conserver AUCUNE échéance de projection — même une échéance unique dont la date
// correspond bien à date_premiere_echeance est une échéance ORPHELINE dans ce cas (le
// prêt est soldé), pas un état cohérent. Sans ce garde-fou, cette route n'aurait jamais
// détecté l'échéance laissée par le bug de "Corriger les dates des prêts différés"
// (ci-dessus) sur un prêt déjà soldé, puisque cette échéance orpheline correspondait
// justement (par construction du bug) à date_premiere_echeance.
const estClos = inv.statut === 'rembourse' || inv.statut === 'cloture';
const incoherent = estClos
? simulRows.length > 0
: (simulRows.length !== 1 || simulRows[0].date_prevue !== inv.date_premiere_echeance);
if (!incoherent) continue;
@@ -405,11 +421,19 @@ router.post('/check-echeancier-differe', (req, res, next) => {
? 'aucune échéance'
: simulRows.map(s => `${s.date_prevue} (n°${s.numero_echeance})`).join(', ');
if (hasReinvest.get(inv.id)) {
generateSimulWithReinvestissements(db, inv.id);
} else {
generateSimul(db, inv);
}
// regenererEcheancier() régénère PUIS réconcilie contre les remboursements réels déjà
// enregistrés (replayAdjustSimulForActuals(), cf. project_revision_correction.md) — pour
// un prêt soldé, cela supprime l'échéance orpheline ; pour un prêt actif dont
// l'échéancier était simplement désynchronisé, cela le reconstruit SANS écraser un
// éventuel remboursement anticipé partiel déjà pris en compte, contrairement à l'ancien
// appel direct à generateSimul()/generateSimulWithReinvestissements() ci-dessous, qui ne
// réconciliait jamais rien.
regenererEcheancier(inv.id);
const nouvelEcheancier = getSimul.all(inv.id);
const nouvelleValeur = nouvelEcheancier.length === 0
? 'aucune échéance (prêt soldé)'
: nouvelEcheancier.map(s => `${s.date_prevue} (n°${s.numero_echeance})`).join(', ');
recordHistory(inv.id, {
type_evenement: 'correction_auto_echeancier',
@@ -417,7 +441,7 @@ router.post('/check-echeancier-differe', (req, res, next) => {
champ: 'echeancier',
label: 'Échéancier de projection',
ancienne_valeur: ancienEcheancier,
nouvelle_valeur: inv.date_premiere_echeance,
nouvelle_valeur: nouvelleValeur,
}],
notes: "Correction automatique (Nettoyage > Vérifier la cohérence de l'échéancier des prêts différés)",
});
@@ -426,7 +450,7 @@ router.post('/check-echeancier-differe', (req, res, next) => {
id: inv.id,
nom_projet: inv.nom_projet,
ancien_echeancier: ancienEcheancier,
nouvelle_date: inv.date_premiere_echeance,
nouvelle_date: nouvelleValeur,
});
}
@@ -488,6 +512,173 @@ router.get('/:id', (req, res, next) => {
} catch (e) { next(e); }
});
/* ── GET /api/investissements/:id/export ─────────────────────────────────
Export ZIP complet d'un "dossier" d'investissement — demande Olivier
29/08/26, rework du bouton "Exporter" du menu ⋮ (bloc "Informations du
projet"), qui ne produisait jusqu'ici qu'un simple .json construit
côté client, sans les documents joints. Contenu du zip :
- manifest.json : toutes les données nécessaires pour recréer le dossier
(investissement, plateforme, investisseur, catégories/secteurs,
remboursements, projections, réinvestissements, historique, révisions,
pertes) + les métadonnées de chaque document (nom, taille, type, et son
chemin dans le zip) — en vue d'une future réimportation (pas encore
implémentée, cf. décision Olivier : seulement l'export pour l'instant).
- documents/<nom>.<ext> : les fichiers eux-mêmes, lus sur disque via
docsDir (partagé avec documents.js). Toujours produit même sans aucun
document — contrairement à l'export global de Mon compte
(GET /api/documents/export), qui lui exige au moins un document
puisque son seul contenu, ce sont les fichiers. ────────────────────── */
router.get('/:id/export', (req, res, next) => {
try {
const inv = db.prepare(`
SELECT i.*, p.nom AS plateforme_nom, cp.nom AS categorie_nom,
c.nom AS compte_nom, c.type AS compte_type
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id
LEFT JOIN comptes c ON c.id = i.compte_id
WHERE i.id = ?
`).get(req.user.id, req.params.id);
if (!inv) throw new HttpError(404, 'Not found');
const investisseur = db.prepare(
'SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?'
).get(inv.investisseur_id, req.user.id);
const remboursements = db.prepare(
'SELECT * FROM remboursements WHERE investissement_id = ? ORDER BY date_remb'
).all(req.params.id);
const simul = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance'
).all(req.params.id);
const historique = db.prepare(
'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC'
).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) }));
const revisions = db.prepare(
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id);
const pertes = db.prepare(
'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id);
const reinvestissements = db.prepare(
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(req.params.id);
const categories_inv = db.prepare(`
SELECT c.nom FROM investissement_categories_inv ic
JOIN categories_inv c ON c.id = ic.categorie_id
WHERE ic.investissement_id = ?
ORDER BY c.nom
`).all(req.params.id).map(r => r.nom);
const secteurs_inv = db.prepare(`
SELECT s.nom FROM investissement_secteurs_inv is2
JOIN secteurs_inv s ON s.id = is2.secteur_id
WHERE is2.investissement_id = ?
ORDER BY s.nom
`).all(req.params.id).map(r => r.nom);
const documents = db.prepare(
"SELECT * FROM documents WHERE user_id = ? AND entity_type = 'investissement' AND entity_id = ? ORDER BY created_at ASC"
).all(req.user.id, req.params.id);
// Fichiers du zip + métadonnées correspondantes dans le manifeste (le nom
// affiché sert de nom de fichier dans le zip, avec gestion des doublons —
// même logique que l'export global de Mon compte, cf. documents.js).
const usedNames = new Set();
const documentsMeta = [];
const entries = [];
for (const doc of documents) {
const baseLabel = sanitizeZipPart(doc.nom_affichage);
let finalName = `${baseLabel}.${doc.extension}`;
let n = 2;
while (usedNames.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; }
usedNames.add(finalName);
const zipPath = `documents/${finalName}`;
documentsMeta.push({
nom_affichage: doc.nom_affichage,
nom_original: doc.nom_original,
extension: doc.extension,
mime_type: doc.mime_type,
taille_octets: doc.taille_octets,
categorie: doc.categorie,
zip_path: zipPath,
});
const filePath = path.join(docsDir, doc.filename);
if (fs.existsSync(filePath)) entries.push({ name: zipPath, data: fs.readFileSync(filePath) });
}
const manifest = {
version: '1.0',
type: 'dossier_investissement',
exported_at: new Date().toISOString(),
investissement: {
nom_projet: inv.nom_projet,
emetteur: inv.emetteur,
date_souscription: inv.date_souscription,
date_premiere_echeance: inv.date_premiere_echeance,
date_cible: inv.date_cible,
date_debut_simul: inv.date_debut_simul,
montant_investi: inv.montant_investi,
taux_interet: inv.taux_interet,
duree_mois: inv.duree_mois,
type_remb: inv.type_remb,
freq_interets: inv.freq_interets,
statut: inv.statut,
reference: inv.reference,
source: inv.source,
notes: inv.notes,
pays_exposition: inv.pays_exposition,
methode_remboursement: inv.methode_remboursement,
echeance_fin_de_mois: inv.echeance_fin_de_mois,
nom_compte_courant: inv.nom_compte_courant,
categorie: inv.categorie_nom || null,
compte: inv.compte_nom ? { nom: inv.compte_nom, type: inv.compte_type } : null,
},
plateforme: { nom: inv.plateforme_nom },
investisseur: investisseur || null,
categories_inv,
secteurs_inv,
remboursements: remboursements.map(r => ({
date_remb: r.date_remb, capital: r.capital, cashback: r.cashback,
interets_bruts: r.interets_bruts, prelev_sociaux: r.prelev_sociaux,
prelev_forfaitaire: r.prelev_forfaitaire, interets_nets: r.interets_nets,
net_recu: r.net_recu, statut: r.statut, notes: r.notes,
})),
projections: simul.map(s => ({
numero_echeance: s.numero_echeance, date_prevue: s.date_prevue,
capital_prevu: s.capital_prevu, interets_prevus: s.interets_prevus, total_prevu: s.total_prevu,
})),
reinvestissements: reinvestissements.map(r => ({
date_reinvestissement: r.date_reinvestissement, montant: r.montant, source: r.source, note: r.note,
})),
historique: historique.map(h => ({
type_evenement: h.type_evenement, changements: h.changements, notes: h.notes, created_at: h.created_at,
})),
revisions: revisions.map(r => ({
date_effet: r.date_effet, ancien_taux: r.ancien_taux, nouveau_taux: r.nouveau_taux,
ancienne_date_cible: r.ancienne_date_cible, nouvelle_date_cible: r.nouvelle_date_cible,
motif: r.motif, created_at: r.created_at,
})),
pertes: pertes.map(p => ({
date_effet: p.date_effet, montant_perte: p.montant_perte, ancien_statut: p.ancien_statut,
motif: p.motif, created_at: p.created_at,
})),
documents: documentsMeta,
};
entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
const zipBuf = createZip(entries);
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `Dossier_Investissement_${req.params.id}_${ts}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(zipBuf);
} catch (e) { next(e); }
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
@@ -617,6 +808,12 @@ router.put('/:id/fiscalite-override', (req, res, next) => {
router.delete('/:id', (req, res, next) => {
try {
// Documents liés à cet investissement (lignes + fichiers sur disque) :
// nettoyés avant l'investissement lui-même. entity_id est un lien
// polymorphe sans contrainte de clé étrangère — rien ne les supprimerait
// sinon, ils resteraient orphelins (demande Olivier 29/08/26).
deleteDocumentsForEntity(req.user.id, 'investissement', Number(req.params.id));
const r = db.prepare(`
DELETE FROM investissements
WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
+710
View File
@@ -0,0 +1,710 @@
import { Router } from 'express';
import { z } from 'zod';
import path from 'node:path';
import fs from 'node: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 { createZip, sanitizeZipPart } from '../utils/zip.js';
import { docsDir } from './documents.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(),
// Capital réellement disponible à déployer, net des frais prélevés "à la source" à la
// souscription (18/09/26, demande Olivier — cf. migration capital_investissable dans
// db/index.js). Calculé et fourni par le front (InvestissementsPe.jsx / InvestissementPeDetail
// .jsx) à partir de l'étape "Frais" de la modale ; NULL si inconnu (deal sans frais à la
// source, ou pas encore resauvegardé depuis cette fonctionnalité) — auquel cas montant_investi
// sert de repli partout où capital_investissable est utilisé.
capital_investissable: z.number().nonnegative().nullable().optional(),
objectif: z.string().optional(),
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
reference: z.string().optional(),
notes: z.string().optional(),
// Mode de détention (19/09/26) — cf. migration mode_detention dans db/index.js.
mode_detention: z.enum(['direct', 'pea_pme', 'assurance_vie']).default('direct'),
});
// Déclaration d'une perte définitive de capital sur un deal PE (20/09/26, chantier
// "Fiscalité PE" — cf. migration investissement_pe_pertes dans db/index.js). Même schéma que
// PerteSchema côté crowdlending (investissements.js) : montant_perte éditable (perte partielle
// possible si une partie du capital a déjà été recouvrée), préfilli côté frontend mais non
// recalculé ici.
const PerteSchema = z.object({
date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
montant_perte: z.number().nonnegative(),
motif: z.string().trim().min(1, 'Le motif est obligatoire'),
});
const ValorisationSchema = z.object({
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
valorisation_nette: z.number().nonnegative(),
// Répartition optionnelle (18/09/26, demande Olivier) : quand capital_deploye est renseigné,
// la valorisation_nette envoyée par le client est recalculée côté serveur (cf. POST
// /:id/valorisations) plutôt que reprise telle quelle — seule source de vérité sur ce calcul.
// Capital non déployé n'existe pas ici : toujours déduit (montant_investi du deal − capital_deploye).
capital_deploye: z.number().nonnegative().optional(),
plus_value_latente: z.number().optional(),
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);
// Filtre optionnel par plateforme (19/09/26, page Plateformes PE) — mirroir du filtre
// plateforme_id déjà supporté par GET /depots-retraits et GET /remboursements.
const { plateforme_id } = req.query;
const platCond = plateforme_id ? 'AND ipe.plateforme_id = ?' : '';
const platArgs = plateforme_id ? [Number(plateforme_id)] : [];
const rows = db.prepare(`
SELECT ipe.*,
p.nom AS plateforme_nom,
p.logo_filename AS plateforme_logo,
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,
-- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé"
-- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora,
-- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET
-- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
AS obligation_cumulee
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 = ? ${platCond}
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...invArgs, workspaceId, ...platArgs);
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);
});
/**
* GET /api/investissements-pe/mouvements-portefeuille — 19/09/26, demande Olivier : "le suivi
* de mouvement du porte-monnaie doit être commun [crowdlending + PE], avec la possibilité de
* distinguer si l'on parle de crowdlending ou de PE".
*
* Contexte du bug signalé : l'onglet "Mouvements Porte-monnaie" (Plateformes.jsx, calcul du
* solde indicatif ligne à ligne via MouvementsShared.computeSoldeIndicatifMap) ne consomme que
* des endpoints bornés au workspace actif (GET /remboursements, /depots-retraits,
* /frais-operations, /investissements) — cohérent pour séparer la performance crowdlending de
* la performance PE, mais faux pour le porte-monnaie : sur une plateforme partagée (ex.
* Fundora), l'argent est UN SEUL porte-monnaie physique chez le courtier, alimenté ou ponctionné
* indifféremment par les deux activités. Un remboursement PE réutilisé pour souscrire un deal
* crowdlending devient alors invisible du côté crowdlending, et le cumul plonge artificiellement
* dans le négatif après chaque retrait — exactement le symptôme observé sur Fundora (3,45 €).
*
* Même principe déjà retenu pour solde_portefeuille (dashboard.js / dashboardPe.js, walletMap,
* 17/09/26) : cet endpoint-ci fournit la partie du porte-monnaie qui vit HORS du workspace
* actuellement actif (typiquement PE, quand appelé depuis Plateformes.jsx en workspace
* crowdlending), pour que le frontend la fusionne avec ce qu'il a déjà côté workspace actif —
* sans double-compte, et sans avoir à assouplir le scope workspace_id des endpoints existants.
* Quatre familles de lignes, déjà mises en forme proche de MouvementsShared (type/montant/
* date_operation/plateforme_id/libelle) pour limiter le travail de fusion côté frontend :
* - souscription : capital déployé à la souscription de chaque deal PE (montant_investi),
* pas encore modélisé comme mouvement ailleurs (pendant PE de la ligne "souscription"
* synthétique que Plateformes.jsx génère déjà pour le crowdlending).
* - interets_plateforme : distributions PE (remboursements.type='interets_plateforme'),
* créditées pour leur net_recu — même convention que interetsPlateformeWalletPerPlat dans
* dashboardPe.js ("net_recu tient déjà compte des prélèvements").
* - frais : frais_operations rattachés à investissement_pe_id, jusqu'ici absents aussi du
* calcul officiel du solde (cf. correctif fraisPerPlat du même jour dans dashboard*.js).
* - depot / retrait : dépôts/retraits manuels (table depots_retraits) enregistrés alors que
* le workspace PE était actif — AJOUTÉ le 19/09/26 suite au signalement Olivier sur Fundora,
* où 4 dépôts manuels de 800 € (un par deal PE) étaient invisibles du porte-monnaie
* crowdlending, expliquant l'essentiel du solde indicatif faussé (bien plus que les 3,45 €
* de distribution initialement en cause). Filtrées sur workspace_id != workspace appelant
* (résolu comme à l'écriture, cf. resolveActiveWorkspaceId) plutôt que sur un id "PE" en
* dur, pour rester correct si un 3e workspace apparaît un jour. `raw_source` porte la valeur
* brute de depots_retraits.source (manuel/import_excel/auto_remboursement) pour que le
* frontend puisse reconstituer le badge "AUTO" comme pour les dépôts crowdlending.
*
* corrections_solde n'a PAS de colonne workspace_id (vérifié en base) : aucune correction ne
* peut donc être "perdue" par ce bug, ce n'était pas un vrai gap malgré ce qu'un premier passage
* avait supposé par prudence.
*/
router.get('/mouvements-portefeuille', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, invArgs, invCondDr, invArgsDr;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [userId];
invCondDr = 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invArgsDr = [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];
invCondDr = 'dr.investisseur_id = ?';
invArgsDr = [id];
}
// Volontairement PAS de filtre workspace_id sur souscriptions/distributions/frais (tables
// dédiées PE) : ce sont par construction des lignes qui vivent hors du workspace actif
// (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée
// entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas
// dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé.
//
// ?native=1 (19/09/26, page Plateformes PE) : bascule ce même endpoint pour un appel fait
// DEPUIS le workspace PE lui-même (plutôt que depuis crowdlending) — dans ce sens, les
// depots_retraits à renvoyer sont ceux DU workspace appelant (workspace_id = callerWorkspaceId),
// pas ceux d'un autre workspace. Les souscriptions/distributions/frais PE restent inchangées
// (déjà natives, sans filtre workspace_id — limite connue si plusieurs workspaces PE existent
// un jour, cf. commentaire ci-dessus, non résolue ici).
const callerWorkspaceId = resolveActiveWorkspaceId(req);
const native = req.query.native === '1';
const souscriptions = db.prepare(`
SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
ipe.date_souscription AS date_operation, ipe.montant_investi AS montant,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
`).all(...invArgs);
const distributions = db.prepare(`
SELECT r.id AS remb_id, ipe.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
r.date_remb AS date_operation, r.net_recu AS montant,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE r.type = 'interets_plateforme' AND ${invCond}
`).all(...invArgs);
const frais = db.prepare(`
SELECT f.id AS frais_id, f.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
f.date_operation, f.montant, f.mode_reglement,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN plateformes p ON p.id = f.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
`).all(...invArgs);
const drWorkspaceOp = native ? '=' : '!=';
const depots = db.prepare(`
SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
dr.date_operation, dr.type, dr.montant, dr.source AS raw_source,
dr.libelle, dr.investisseur_id, inv.nom AS investisseur_nom
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = dr.investisseur_id
WHERE dr.workspace_id ${drWorkspaceOp} ? AND ${invCondDr}
`).all(callerWorkspaceId, ...invArgsDr);
const rows = [
...souscriptions.map(r => ({
id: `pe_sub_${r.deal_id}`, type: 'souscription', montant: r.montant,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...distributions.map(r => ({
id: `pe_remb_${r.remb_id}`, type: 'interets_plateforme', montant: r.montant,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...frais.map(r => ({
id: `pe_frais_${r.frais_id}`, type: 'frais', montant: r.montant, mode_reglement: r.mode_reglement,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...depots.map(r => ({
id: `pe_dr_${r.dr_id}`, type: r.type, montant: r.montant, raw_source: r.raw_source,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
];
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, capital_investissable, objectif, statut, reference, notes,
mode_detention)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
body.date_souscription, body.duree_mois ?? null, body.montant_investi,
body.capital_investissable ?? null, body.objectif || null,
body.statut, body.reference || null, body.notes || null, body.mode_detention,
);
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,
p.logo_filename AS plateforme_logo,
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,
-- Sous-ensemble des distributions ci-dessus classées "intérêt sur capital non déployé"
-- (18/09/26, demande Olivier — reconstitue la ligne "Obligation" des captures Fundora,
-- distincte de la restitution de capital déployé). Inclus dans distributions_cumulees ET
-- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
AS obligation_cumulee,
-- Idem, pour la part "plus-value réalisée" des distributions (20/09/26, chantier
-- "Fiscalité PE" — cf. distribution_nature dans RembFormModal.jsx). Inclus dans
-- distributions_cumulees ET dans le TVPI, isolé ici pour l'affichage/le calcul fiscal.
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'plus_value'), 0)
AS plus_value_cumulee,
-- Idem, pour la part "restitution de capital déployé" — sert à préfiller le montant par
-- défaut de la modale "Déclarer une perte définitive" (capital pas encore restitué).
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'capital'), 0)
AS capital_restitue_cumule
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;
// Pertes définitives (chantier "Fiscalité PE", 20/09/26) — au plus une ligne active à la
// fois en pratique (statut repasse à perte_definitive), mais l'historique complet
// (déclarations annulées comprises) est conservé pour traçabilité, comme
// investissement_pertes côté crowdlending.
row.pertes = db.prepare(
'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id ASC'
).all(id);
res.json(row);
} catch (e) { next(e); }
});
/**
* GET /api/investissements-pe/:id/export — dossier ZIP d'un deal PE (18/09/26, demande
* Olivier — menu "⋮" de la fiche détail, inspiré du même menu côté crowdlending). Même
* principe que GET /investissements/:id/export (manifest.json + documents/), mais le
* contenu du manifeste reflète le modèle de données PE, plus réduit : pas d'échéancier
* prévisionnel, pas d'historique de modifications, pas de révisions de conditions, pas de
* pertes ni réinvestissements pour cette table (concepts qui n'existent pas côté PE) — en
* repli, valorisations + remboursements (distributions) + frais_operations + documents.
*/
router.get('/:id/export', (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const meta = db.prepare(`
SELECT ipe.*, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
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);
const valorisations = db.prepare(
'SELECT date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes FROM investissements_pe_valorisations WHERE investissement_pe_id = ? ORDER BY date_valorisation ASC, id ASC'
).all(id);
const remboursements = db.prepare(
'SELECT date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, distribution_nature, notes FROM remboursements WHERE investissement_pe_id = ? ORDER BY date_remb ASC'
).all(id);
const frais = db.prepare(
'SELECT date_operation, montant, mode_reglement, origine, categorie, notes FROM frais_operations WHERE investissement_pe_id = ? ORDER BY date_operation ASC'
).all(id);
const documents = db.prepare(
"SELECT * FROM documents WHERE user_id = ? AND entity_type = 'investissement_pe' AND entity_id = ? ORDER BY created_at ASC"
).all(req.user.id, id);
// Fichiers du zip + métadonnées correspondantes dans le manifeste — même logique de
// dédoublonnage des noms que l'export crowdlending (cf. investissements.js).
const usedNames = new Set();
const documentsMeta = [];
const entries = [];
for (const doc of documents) {
const baseLabel = sanitizeZipPart(doc.nom_affichage);
let finalName = `${baseLabel}.${doc.extension}`;
let n = 2;
while (usedNames.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; }
usedNames.add(finalName);
const zipPath = `documents/${finalName}`;
documentsMeta.push({
nom_affichage: doc.nom_affichage,
nom_original: doc.nom_original,
extension: doc.extension,
mime_type: doc.mime_type,
taille_octets: doc.taille_octets,
categorie: doc.categorie,
zip_path: zipPath,
});
const filePath = path.join(docsDir, doc.filename);
if (fs.existsSync(filePath)) entries.push({ name: zipPath, data: fs.readFileSync(filePath) });
}
const manifest = {
version: '1.0',
type: 'dossier_investissement_pe',
exported_at: new Date().toISOString(),
investissement_pe: {
nom_deal: meta.nom_deal,
strategie: meta.strategie,
date_souscription: meta.date_souscription,
duree_mois: meta.duree_mois,
montant_investi: meta.montant_investi,
capital_investissable: meta.capital_investissable,
objectif: meta.objectif,
statut: meta.statut,
reference: meta.reference,
notes: meta.notes,
},
plateforme: { nom: meta.plateforme_nom },
investisseur: { nom: meta.investisseur_nom },
valorisations,
remboursements,
frais_operations: frais,
documents: documentsMeta,
};
entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
const zipBuf = createZip(entries);
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `Dossier_PE_${id}_${ts}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(zipBuf);
} 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=?, capital_investissable=?, objectif=?, statut=?,
reference=?, notes=?, mode_detention=?, updated_at=datetime('now')
WHERE id=?
`).run(
investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription,
body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null,
body.objectif || null, body.statut, body.reference || null, body.notes || null,
body.mode_detention, 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); }
});
/* ── Perte définitive (chantier "Fiscalité PE", 20/09/26) ───────
* POST /:id/perte-definitive — déclare un deal en perte définitive (capital réellement
* irrécouvrable : fonds liquidé sans distribution, société sous-jacente radiée…).
* Contrairement au crowdlending, un deal PE n'a pas de statut intermédiaire de
* recouvrement (en_retard/procedure) : n'importe quel statut autre que
* 'perte_definitive' peut y transiter directement.
* DELETE /:id/perte-definitive — annule la dernière déclaration, restaure le statut précédent.
*/
router.post('/:id/perte-definitive', requireInvestisseur, (req, res, next) => {
try {
const body = PerteSchema.parse(req.body);
const id = Number(req.params.id);
const deal = ownedDeal(req, id);
if (deal.statut === 'perte_definitive') {
throw new HttpError(400, 'Ce deal est déjà déclaré en perte définitive.');
}
let perteId;
const tx = db.transaction(() => {
const r = db.prepare(`
INSERT INTO investissement_pe_pertes (investissement_pe_id, date_effet, montant_perte, ancien_statut, motif)
VALUES (?,?,?,?,?)
`).run(id, body.date_effet, body.montant_perte, deal.statut, body.motif);
perteId = r.lastInsertRowid;
db.prepare(`
UPDATE investissements_pe SET statut = 'perte_definitive', updated_at = datetime('now') WHERE id = ?
`).run(id);
});
tx();
const perte = db.prepare('SELECT * FROM investissement_pe_pertes WHERE id = ?').get(perteId);
res.status(201).json(perte);
} catch (e) { next(e); }
});
router.delete('/:id/perte-definitive', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
const deal = ownedDeal(req, id);
if (deal.statut !== 'perte_definitive') {
throw new HttpError(400, "Ce deal n'est pas déclaré en perte définitive.");
}
const derniere = db.prepare(
'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id DESC LIMIT 1'
).get(id);
const statutRestaure = derniere?.ancien_statut || 'valide';
const tx = db.transaction(() => {
if (derniere) db.prepare('DELETE FROM investissement_pe_pertes WHERE id = ?').run(derniere.id);
db.prepare(`
UPDATE investissements_pe SET statut = ?, updated_at = datetime('now') WHERE id = ?
`).run(statutRestaure, id);
});
tx();
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);
const deal = ownedDeal(req, id);
const body = ValorisationSchema.parse(req.body);
// Quand la répartition est renseignée (capital_deploye présent), la valorisation nette est
// recalculée ici plutôt que reprise du body : capital_deploye + plus_value_latente +
// capital_non_deploye (déduit = capital investissable du deal − capital_deploye, jamais
// négatif). Capital investissable = montant_investi net des frais "à la source" prélevés à
// la souscription quand ils sont connus (deal.capital_investissable), sinon montant_investi
// brut par repli (18/09/26, correctif Olivier — jusque-là on utilisait toujours le montant
// brut, ce qui gonflait à tort le capital non déployé pour un deal avec frais à la source).
// Sans répartition (mode simple, historique), comportement inchangé : le montant saisi est
// utilisé tel quel et les deux colonnes restent NULL.
let valorisationNette = body.valorisation_nette;
let capitalDeploye = null;
let plusValueLatente = null;
if (body.capital_deploye != null) {
const capitalInvestissable = deal.capital_investissable ?? deal.montant_investi;
capitalDeploye = body.capital_deploye;
plusValueLatente = body.plus_value_latente ?? 0;
const capitalNonDeploye = Math.max(0, capitalInvestissable - capitalDeploye);
valorisationNette = Math.round((capitalDeploye + plusValueLatente + capitalNonDeploye) * 100) / 100;
}
const r = db.prepare(`
INSERT INTO investissements_pe_valorisations
(investissement_pe_id, date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes)
VALUES (?,?,?,?,?,?)
`).run(id, body.date_valorisation, valorisationNette, capitalDeploye, plusValueLatente, body.notes || null);
res.status(201).json({
id: r.lastInsertRowid,
investissement_pe_id: id,
date_valorisation: body.date_valorisation,
valorisation_nette: valorisationNette,
capital_deploye: capitalDeploye,
plus_value_latente: plusValueLatente,
notes: body.notes,
});
} 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;
+8 -2
View File
@@ -45,9 +45,15 @@ router.post('/', (req, res, next) => {
// Auto-créer un compte courant pour ce nouveau profil
// body.nom contient déjà le nom complet (ex. "Marine CROGUENNEC")
const label = `Compte courant — ${body.nom}`;
// Rattache directement l'enveloppe "Compte courant non rémunéré" et marque ce compte comme
// compte de règlement par défaut — plus besoin de le faire à la main ensuite dans
// Paramètres > Mes comptes courants (28/09/26 (suite), demande Olivier).
const enveloppeCompteCourantNonRemunere = db.prepare(
"SELECT id FROM enveloppes_referentiel WHERE nom = 'Compte courant non rémunéré'"
).get();
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(req.user.id, label, 'compte_courant', invId);
'INSERT INTO comptes (user_id, nom, type, enveloppe_id, investisseur_id, compte_reglement_par_defaut) VALUES (?,?,?,?,?,1)'
).run(req.user.id, label, 'compte_courant', enveloppeCompteCourantNonRemunere?.id ?? null, invId);
res.status(201).json({ id: invId, ...body });
} catch (e) { next(e); }
+20 -4
View File
@@ -15,7 +15,7 @@ import { Router } from 'express';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import db from '../db/index.js';
import db, { grantDefaultWorkspace } from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
import { audit } from '../utils/audit.js';
@@ -84,7 +84,14 @@ router.post('/', async (req, res, next) => {
// Envoyer l'email
const cfg = getSmtpConfig();
const appUrl = cfg.appUrl || '';
// Retire le / final avant de concaténer (19/09/26 — bug corrigé : app_url est
// habituellement saisi avec un slash final par les admins, ex. "https://mon-domaine/",
// et sans ce trim le lien devenait "https://mon-domaine//invitation/<token>". Ce double
// slash ne matche pas la route React Router "/invitation/:token", qui tombe alors sur le
// catch-all protégé de App.jsx et redirige silencieusement l'utilisateur invité vers
// /login au lieu de la page d'inscription. Même correctif déjà appliqué partout ailleurs
// (auth.js : vérification email, reset de mot de passe).
const appUrl = cfg.appUrl?.replace(/\/$/, '') || '';
const inviteUrl = `${appUrl}/invitation/${token}`;
const roleLabel = role === 'admin' ? 'Administrateur' : 'Utilisateur';
@@ -187,9 +194,18 @@ router.post('/:token/register', async (req, res, next) => {
const invResult = db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
).run(pendingUser.id, fullName, prenom);
// Rattache directement l'enveloppe "Compte courant non rémunéré" et marque ce compte comme
// compte de règlement par défaut (28/09/26 (suite), demande Olivier).
const enveloppeCompteCourantNonRemunere = db.prepare(
"SELECT id FROM enveloppes_referentiel WHERE nom = 'Compte courant non rémunéré'"
).get();
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(pendingUser.id, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
'INSERT INTO comptes (user_id, nom, type, enveloppe_id, investisseur_id, compte_reglement_par_defaut) VALUES (?,?,?,?,?,1)'
).run(pendingUser.id, `Compte courant — ${fullName}`, 'compte_courant', enveloppeCompteCourantNonRemunere?.id ?? null, invResult.lastInsertRowid);
// Accorde l'accès au workspace socle (crowdlending) à tout nouveau compte
// — cf. project_workspaces_transformation.md.
grantDefaultWorkspace(pendingUser.id);
// Marquer l'invitation comme utilisée
db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id);
+109
View File
@@ -0,0 +1,109 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { recordValorisation } from '../utils/valorisationsPatrimoine.js';
const router = Router();
/**
* routes/lignesPatrimoine.js — CRUD pour lignes_patrimoine (chantier "Visualisation d'un
* actif", relance Phase 3b, 30/09/26). Cf. commentaire de migration dans db/index.js pour le
* contexte complet et claude/plan_visualisation_actif.md pour le cadrage.
*
* Modelé sur routes/comptes.js (même structure Schema/GET/POST/PUT/DELETE, scope user_id).
* `enveloppe_id` non contraint à eligible_compte=0 côté Zod (même principe que
* comptes.js:enveloppe_id, non contraint à eligible_compte=1) — le filtrage se fait côté
* frontend, qui ne proposera cette création que depuis un regroupement structurellement vide
* (Immobilier, Autres actifs, Crypto hors exchange...), mais rien n'empêche techniquement une
* ligne_patrimoine sur une enveloppe eligible_compte=1 si un besoin apparaît plus tard.
*/
const Schema = z.object({
nom: z.string().min(1),
investisseur_id: z.number().int().positive().nullable().optional(),
enveloppe_id: z.number().int().positive(),
institution_id: z.number().int().positive().nullable().optional(),
valeur_eur: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT lp.id, lp.nom, lp.investisseur_id, lp.enveloppe_id, lp.institution_id,
lp.valeur_eur, lp.notes, lp.created_at, lp.updated_at,
inv.nom AS investisseur_nom,
er.nom AS enveloppe_nom, er.regroupement_patrimonial_id AS regroupement_patrimonial_id,
ir.nom AS institution_nom,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
FROM lignes_patrimoine lp
LEFT JOIN investisseurs inv ON inv.id = lp.investisseur_id
JOIN enveloppes_referentiel er ON er.id = lp.enveloppe_id
LEFT JOIN institutions_referentiel ir ON ir.id = lp.institution_id
WHERE lp.user_id = ?
ORDER BY lp.nom
`).all(req.user.id);
res.json(rows);
});
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare(`
SELECT lp.id, lp.nom, lp.investisseur_id, lp.enveloppe_id, lp.institution_id,
lp.valeur_eur, lp.notes, lp.created_at, lp.updated_at,
inv.nom AS investisseur_nom,
er.nom AS enveloppe_nom, er.regroupement_patrimonial_id AS regroupement_patrimonial_id,
ir.nom AS institution_nom,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
FROM lignes_patrimoine lp
LEFT JOIN investisseurs inv ON inv.id = lp.investisseur_id
JOIN enveloppes_referentiel er ON er.id = lp.enveloppe_id
LEFT JOIN institutions_referentiel ir ON ir.id = lp.institution_id
WHERE lp.id = ? AND lp.user_id = ?
`).get(req.params.id, req.user.id);
if (!row) throw new HttpError(404, 'Not found');
res.json(row);
} catch (e) { next(e); }
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const id = db.transaction(() => {
const r = db.prepare(
'INSERT INTO lignes_patrimoine (user_id, investisseur_id, enveloppe_id, institution_id, nom, valeur_eur, notes) VALUES (?,?,?,?,?,?,?)'
).run(req.user.id, body.investisseur_id ?? null, body.enveloppe_id, body.institution_id ?? null, body.nom, body.valeur_eur ?? null, body.notes ?? null);
recordValorisation({ lignePatrimoineId: r.lastInsertRowid, valeur: body.valeur_eur });
return r.lastInsertRowid;
})();
res.status(201).json({ id, ...body });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const changes = db.transaction(() => {
const result = db.prepare(
`UPDATE lignes_patrimoine SET investisseur_id=?, enveloppe_id=?, institution_id=?, nom=?, valeur_eur=?, notes=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.investisseur_id ?? null, body.enveloppe_id, body.institution_id ?? null, body.nom, body.valeur_eur ?? null, body.notes ?? null, req.params.id, req.user.id).changes;
if (result > 0) recordValorisation({ lignePatrimoineId: Number(req.params.id), valeur: body.valeur_eur });
return result;
})();
if (changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
// ON DELETE CASCADE sur transactions_patrimoine.ligne_patrimoine_id (cf. migration,
// db/index.js) : supprimer une ligne_patrimoine supprime aussi ses transactions.
const r = db.prepare('DELETE FROM lignes_patrimoine WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+149 -19
View File
@@ -3,13 +3,18 @@ import { z } from 'zod';
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';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const logosDir = path.resolve(__dirname, '../../../data/logos');
const dataDir = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: path.resolve(__dirname, '../../../data'); // fallback dev : 3 niveaux au-dessus de routes/
const logosDir = path.join(dataDir, 'logos');
fs.mkdirSync(logosDir, { recursive: true });
/** Sanitise un nom de plateforme pour en faire un nom de fichier safe */
@@ -53,16 +58,20 @@ const Schema = z.object({
fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'),
taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(),
type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'),
categorie_plateforme: z.enum(['psfp', 'psan']).default('psfp'),
frais_plateforme: z.enum(['aucun', 'entree_gestion_directe']).default('aucun'),
methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).default('portefeuille'),
investisseur_id: z.number().int().positive().nullable().optional(),
date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
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)
const HERITABLE_FIELDS = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'icone_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut'];
const HERITABLE_FIELDS = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'categorie_plateforme', 'frais_plateforme', 'logo_filename', 'icone_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut'];
/**
* Calcule overridden_fields : champs dont la valeur diffère du référentiel.
@@ -217,22 +226,76 @@ 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(`
SELECT pr.id, pr.nom, pr.url, pr.domiciliation, pr.fiscalite,
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename,
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.categorie_plateforme, pr.frais_plateforme, pr.logo_filename, pr.icone_filename,
pr.methode_remboursement, pr.type_pret_defaut, pr.freq_interets_defaut
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) => {
const rows = db.prepare(`
SELECT p.id, p.nom, p.url, p.notes, p.domiciliation, p.fiscalite, p.taux_fiscalite_locale,
p.type_produit_fiscal,
p.type_produit_fiscal, p.categorie_plateforme, p.frais_plateforme,
p.methode_remboursement, p.investisseur_id, p.date_ouverture, p.logo_filename, p.icone_filename, p.created_at,
p.type_pret_defaut, p.freq_interets_defaut,
p.referentiel_id, p.overridden_fields,
@@ -255,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';
@@ -282,15 +355,15 @@ router.post('/', (req, res, next) => {
const r = db.prepare(`
INSERT INTO plateformes
(user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal,
(user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, categorie_plateforme, frais_plateforme,
methode_remboursement, investisseur_id, date_ouverture,
type_pret_defaut, freq_interets_defaut,
logo_filename, icone_filename,
referentiel_id, overridden_fields)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
req.user.id, body.nom, body.url || null, body.notes || null,
body.domiciliation, fiscalite, taux, typeProduitFiscal,
body.domiciliation, fiscalite, taux, typeProduitFiscal, body.categorie_plateforme, body.frais_plateforme,
body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null,
body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null,
logoFilename, iconeFilename,
@@ -299,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,
@@ -329,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);
@@ -357,6 +432,8 @@ router.get('/export', (req, res, next) => {
fiscalite: r.fiscalite,
taux_fiscalite_locale: r.taux_fiscalite_locale,
type_produit_fiscal: r.type_produit_fiscal,
categorie_plateforme: r.categorie_plateforme,
frais_plateforme: r.frais_plateforme,
methode_remboursement: r.methode_remboursement,
date_ouverture: r.date_ouverture,
type_pret_defaut: r.type_pret_defaut,
@@ -369,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) });
@@ -409,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
@@ -425,6 +504,8 @@ router.get('/:id/export', (req, res, next) => {
domiciliation: withAll.domiciliation, fiscalite: withAll.fiscalite,
taux_fiscalite_locale: withAll.taux_fiscalite_locale,
type_produit_fiscal: withAll.type_produit_fiscal,
categorie_plateforme: withAll.categorie_plateforme,
frais_plateforme: withAll.frais_plateforme,
methode_remboursement: withAll.methode_remboursement,
date_ouverture: withAll.date_ouverture,
type_pret_defaut: withAll.type_pret_defaut, freq_interets_defaut: withAll.freq_interets_defaut,
@@ -433,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) });
@@ -531,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 type = '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;
@@ -547,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;
@@ -554,7 +656,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
const fields = [
p.url || null, p.notes || null,
p.domiciliation || 'france', fiscalite, taux,
p.type_produit_fiscal || '2TT', p.methode_remboursement || 'portefeuille',
p.type_produit_fiscal || '2TT', p.categorie_plateforme || 'psfp', p.frais_plateforme || 'aucun', p.methode_remboursement || 'portefeuille',
investisseurId, p.date_ouverture || null,
p.type_pret_defaut || null, p.freq_interets_defaut || null,
p.logo_filename || null, p.icone_filename || null,
@@ -563,7 +665,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
if (existing) {
db.prepare(`
UPDATE plateformes SET url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?,
type_produit_fiscal=?, categorie_plateforme=?, frais_plateforme=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?,
type_pret_defaut=?, freq_interets_defaut=?,
logo_filename=?, icone_filename=?
WHERE id=? AND user_id=?
@@ -574,10 +676,10 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
const r = db.prepare(`
INSERT INTO plateformes
(user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale,
type_produit_fiscal, methode_remboursement, investisseur_id, date_ouverture,
type_produit_fiscal, categorie_plateforme, frais_plateforme, methode_remboursement, investisseur_id, date_ouverture,
type_pret_defaut, freq_interets_defaut,
logo_filename, icone_filename, overridden_fields)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(req.user.id, p.nom, ...fields, '[]');
platId = r.lastInsertRowid;
created++;
@@ -591,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]) {
@@ -617,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) {
@@ -628,6 +744,8 @@ router.put('/:id', (req, res, next) => {
fiscalite,
taux_fiscalite_locale: taux,
type_produit_fiscal: typeProduitFiscal,
categorie_plateforme: body.categorie_plateforme,
frais_plateforme: body.frais_plateforme,
logo_filename: current.logo_filename, // logo géré séparément
methode_remboursement: body.methode_remboursement,
type_pret_defaut: body.type_pret_defaut ?? null,
@@ -639,19 +757,20 @@ router.put('/:id', (req, res, next) => {
const r = db.prepare(`
UPDATE plateformes
SET nom=?, url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?,
type_produit_fiscal=?, categorie_plateforme=?, frais_plateforme=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?,
type_pret_defaut=?, freq_interets_defaut=?,
overridden_fields=?
WHERE id=? AND user_id=?
`).run(
body.nom, body.url || null, body.notes || null, body.domiciliation, fiscalite, taux,
typeProduitFiscal, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null,
typeProduitFiscal, body.categorie_plateforme, body.frais_plateforme, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null,
body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null,
JSON.stringify(overriddenFields),
req.params.id, req.user.id
);
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,
@@ -722,7 +841,7 @@ router.post('/:id/purge-donnees', (req, res, next) => {
}
const platId = plat.id;
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 };
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0, documents: 0 };
const tx = db.transaction(() => {
if (scope === 'depots_retraits' || scope === 'all') {
@@ -736,6 +855,12 @@ router.post('/:id/purge-donnees', (req, res, next) => {
`).run(platId).changes;
}
if (scope === 'investissements' || scope === 'all') {
// Récupérés avant suppression : sert à nettoyer les documents liés à
// ces investissements juste après (aucune contrainte de clé étrangère
// ne les supprime automatiquement, cf. entity_id polymorphe) — une
// fois les investissements supprimés, leurs ids ne sont plus retrouvables.
const invIds = db.prepare('SELECT id FROM investissements WHERE plateforme_id = ?').all(platId).map(r => r.id);
// Compte avant suppression : les remboursements et l'échéancier simulé sont
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
counts.remboursements = db.prepare(`
@@ -747,6 +872,11 @@ router.post('/:id/purge-donnees', (req, res, next) => {
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).get(platId).n;
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
// Documents liés à ces investissements (lignes + fichiers sur disque).
for (const invId of invIds) {
counts.documents += deleteDocumentsForEntity(req.user.id, 'investissement', invId);
}
}
});
tx();
@@ -769,12 +899,12 @@ router.post('/:id/reset', (req, res, next) => {
db.prepare(`
UPDATE plateformes
SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, logo_filename=?, methode_remboursement=?,
type_produit_fiscal=?, categorie_plateforme=?, frais_plateforme=?, logo_filename=?, methode_remboursement=?,
type_pret_defaut=?, freq_interets_defaut=?, overridden_fields='[]'
WHERE id=? AND user_id=?
`).run(
ref.nom, ref.url ?? null, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null,
ref.type_produit_fiscal, ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille',
ref.type_produit_fiscal, ref.categorie_plateforme || 'psfp', ref.frais_plateforme || 'aucun', ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille',
ref.type_pret_defaut ?? null, ref.freq_interets_defaut ?? null,
req.params.id, req.user.id
);
+34 -1
View File
@@ -10,7 +10,19 @@ const ALLOWED_KEYS = new Set([
'chart_cashback',
'pfo_assujetti',
'objectifs_actifs',
// Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…)
'color_theme',
'active_workspace_id',
// Extensible : ajouter ici les futures prefs (font_scale, langue, devise, display_mode…)
]);
// Thème de couleur (Admin > Général, cf. project_theme_interface.md) — même
// énumération que la colonne parametres_theme.color_theme côté admin.
// 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).
export const COLOR_THEME_VALUES = new Set([
'classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil',
'violet', 'frais', 'contraste', 'pastel', 'moderne',
]);
/**
@@ -51,6 +63,10 @@ router.patch('/', (req, res) => {
return res.status(400).json({ error: `Clés inconnues : ${unknown.join(', ')}` });
}
if ('color_theme' in payload && !COLOR_THEME_VALUES.has(payload.color_theme)) {
return res.status(400).json({ error: `Valeur invalide pour color_theme : ${payload.color_theme}` });
}
const persist = db.transaction(() => {
for (const [key, value] of Object.entries(payload)) {
upsert.run(req.user.id, key, String(value));
@@ -61,4 +77,21 @@ router.patch('/', (req, res) => {
res.json({ ok: true });
});
/**
* DELETE /api/preferences/:key
* Supprime la préférence de l'utilisateur connecté pour une clé donnée —
* utile pour revenir à un comportement "par défaut" (ex. "Automatique"
* pour le thème de couleur, cf. Settings > Apparence) plutôt que de
* stocker une valeur sentinelle : une clé absente n'est simplement plus
* retournée par GET (le frontend retombe alors sur sa valeur par défaut).
*/
router.delete('/:key', (req, res) => {
const { key } = req.params;
if (!ALLOWED_KEYS.has(key)) {
return res.status(400).json({ error: `Clé inconnue : ${key}` });
}
db.prepare('DELETE FROM user_preferences WHERE user_id = ? AND key = ?').run(req.user.id, key);
res.json({ ok: true });
});
export default router;
@@ -0,0 +1,104 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/ref-categories-enveloppes.js — liste globale des catégories (familles) du référentiel
* "Enveloppes & Supports d'épargne" (Comptes courants, Épargne réglementée, Assurance-vie...).
* Même patron que routes/ref-categories-institutions.js (categories_institutions), table dédiée
* (categories_enveloppes) — cf. enveloppesReferentiel.js.
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
/* GET /api/ref-categories-enveloppes — liste complète */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT c.id, c.nom,
COUNT(rc.enveloppe_id) AS nb_utilises
FROM categories_enveloppes c
LEFT JOIN referentiel_categories_enveloppes rc ON rc.categorie_id = c.id
GROUP BY c.id
ORDER BY c.nom
`).all();
res.json(rows);
});
/* POST /api/ref-categories-enveloppes — créer */
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const exists = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?').get(nom.trim());
if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
const r = db.prepare('INSERT INTO categories_enveloppes (nom) VALUES (?)').run(nom.trim());
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 });
} catch (e) { next(e); }
});
/* PUT /api/ref-categories-enveloppes/:id — renommer (propage automatiquement) */
router.put('/:id', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const row = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const dup = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ? AND id != ?').get(nom.trim(), row.id);
if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
db.prepare('UPDATE categories_enveloppes SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_enveloppes WHERE categorie_id = ?').get(row.id).n;
res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb });
} catch (e) { next(e); }
});
/* DELETE /api/ref-categories-enveloppes/:id — supprimer si non utilisée */
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM categories_enveloppes WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_enveloppes WHERE categorie_id = ?').get(row.id).n;
if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} enveloppe(s) — retirez-la d'abord.`);
db.prepare('DELETE FROM categories_enveloppes WHERE id = ?').run(row.id);
res.status(204).end();
} catch (e) { next(e); }
});
/* POST /api/ref-categories-enveloppes/:id/merge — fusionner dans une autre catégorie */
router.post('/:id/merge', (req, res, next) => {
try {
const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body);
const source = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(req.params.id);
if (!source) throw new HttpError(404, 'Catégorie source introuvable');
const target = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(target_id);
if (!target) throw new HttpError(404, 'Catégorie cible introuvable');
if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques');
const result = db.transaction(() => {
const refs = db.prepare(
'SELECT enveloppe_id FROM referentiel_categories_enveloppes WHERE categorie_id = ?'
).all(source.id);
let added = 0, skipped = 0;
for (const { enveloppe_id } of refs) {
const exists = db.prepare(
'SELECT 1 FROM referentiel_categories_enveloppes WHERE enveloppe_id = ? AND categorie_id = ?'
).get(enveloppe_id, target.id);
if (!exists) {
db.prepare(
'INSERT INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)'
).run(enveloppe_id, target.id);
added++;
} else {
skipped++;
}
}
db.prepare('DELETE FROM referentiel_categories_enveloppes WHERE categorie_id = ?').run(source.id);
db.prepare('DELETE FROM categories_enveloppes WHERE id = ?').run(source.id);
return { added, skipped, total: refs.length };
})();
res.json({ ok: true, ...result,
message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` });
} catch (e) { next(e); }
});
export default router;
@@ -0,0 +1,104 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/ref-categories-institutions.js — liste globale des catégories du référentiel
* "Institutions financières" (type d'établissement : Banque, Courtier, Assureur...).
* Même patron que routes/ref-categories.js (categories_inv), table dédiée
* (categories_institutions) — cf. institutionsReferentiel.js.
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
/* GET /api/ref-categories-institutions — liste complète */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT c.id, c.nom,
COUNT(rc.institution_id) AS nb_utilises
FROM categories_institutions c
LEFT JOIN referentiel_categories_institutions rc ON rc.categorie_id = c.id
GROUP BY c.id
ORDER BY c.nom
`).all();
res.json(rows);
});
/* POST /api/ref-categories-institutions — créer */
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const exists = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?').get(nom.trim());
if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
const r = db.prepare('INSERT INTO categories_institutions (nom) VALUES (?)').run(nom.trim());
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 });
} catch (e) { next(e); }
});
/* PUT /api/ref-categories-institutions/:id — renommer (propage automatiquement) */
router.put('/:id', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const row = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const dup = db.prepare('SELECT id FROM categories_institutions WHERE nom = ? AND id != ?').get(nom.trim(), row.id);
if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
db.prepare('UPDATE categories_institutions SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_institutions WHERE categorie_id = ?').get(row.id).n;
res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb });
} catch (e) { next(e); }
});
/* DELETE /api/ref-categories-institutions/:id — supprimer si non utilisée */
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM categories_institutions WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_institutions WHERE categorie_id = ?').get(row.id).n;
if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} institution(s) — retirez-la d'abord.`);
db.prepare('DELETE FROM categories_institutions WHERE id = ?').run(row.id);
res.status(204).end();
} catch (e) { next(e); }
});
/* POST /api/ref-categories-institutions/:id/merge — fusionner dans une autre catégorie */
router.post('/:id/merge', (req, res, next) => {
try {
const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body);
const source = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(req.params.id);
if (!source) throw new HttpError(404, 'Catégorie source introuvable');
const target = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(target_id);
if (!target) throw new HttpError(404, 'Catégorie cible introuvable');
if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques');
const result = db.transaction(() => {
const refs = db.prepare(
'SELECT institution_id FROM referentiel_categories_institutions WHERE categorie_id = ?'
).all(source.id);
let added = 0, skipped = 0;
for (const { institution_id } of refs) {
const exists = db.prepare(
'SELECT 1 FROM referentiel_categories_institutions WHERE institution_id = ? AND categorie_id = ?'
).get(institution_id, target.id);
if (!exists) {
db.prepare(
'INSERT INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)'
).run(institution_id, target.id);
added++;
} else {
skipped++;
}
}
db.prepare('DELETE FROM referentiel_categories_institutions WHERE categorie_id = ?').run(source.id);
db.prepare('DELETE FROM categories_institutions WHERE id = ?').run(source.id);
return { added, skipped, total: refs.length };
})();
res.json({ ok: true, ...result,
message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` });
} catch (e) { next(e); }
});
export default router;
+78 -16
View File
@@ -77,6 +77,8 @@ const Schema = z.object({
fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'),
taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(),
type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'),
categorie_plateforme: z.enum(['psfp', 'psan']).default('psfp'),
frais_plateforme: z.enum(['aucun', 'entree_gestion_directe']).default('aucun'),
methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).optional().default('portefeuille'),
type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(),
freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(),
@@ -108,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),
@@ -179,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;
@@ -211,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);
@@ -236,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(`
@@ -265,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);
@@ -286,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 = [];
@@ -321,6 +353,8 @@ router.get('/export', (req, res, next) => {
fiscalite: r.fiscalite,
taux_fiscalite_locale: r.taux_fiscalite_locale,
type_produit_fiscal: r.type_produit_fiscal,
categorie_plateforme: r.categorie_plateforme,
frais_plateforme: r.frais_plateforme,
description: r.description,
annee_creation: r.annee_creation,
investisseurs_types: r.investisseurs_types,
@@ -349,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] || [],
}));
@@ -384,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 = {
@@ -417,6 +452,7 @@ router.get('/:id/export', (req, res, next) => {
fiscalite: enriched.fiscalite,
taux_fiscalite_locale: enriched.taux_fiscalite_locale,
type_produit_fiscal: enriched.type_produit_fiscal,
frais_plateforme: enriched.frais_plateforme,
description: enriched.description,
annee_creation: enriched.annee_creation,
investisseurs_types: enriched.investisseurs_types,
@@ -445,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,
};
@@ -501,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 type = '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) {
@@ -517,13 +573,14 @@ 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);
const fields = [
p.url || null, p.domiciliation || 'france', p.fiscalite || 'flat_tax',
p.taux_fiscalite_locale ?? null, p.type_produit_fiscal || '2TT',
p.taux_fiscalite_locale ?? null, p.type_produit_fiscal || '2TT', p.categorie_plateforme || 'psfp', p.frais_plateforme || 'aucun',
p.methode_remboursement || 'portefeuille', p.type_pret_defaut ?? null, p.freq_interets_defaut ?? null,
p.description ?? null, p.annee_creation ?? null,
p.investisseurs_types ?? null,
@@ -541,7 +598,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
if (existing) {
db.prepare(`
UPDATE plateformes_referentiel SET
url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?,
url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, categorie_plateforme=?, frais_plateforme=?,
methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?,
description=?, annee_creation=?, investisseurs_types=?,
regulateur=?, numero_licence=?, is_regule=?,
@@ -559,7 +616,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
} else {
const r = db.prepare(`
INSERT INTO plateformes_referentiel
(nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal,
(nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, categorie_plateforme, frais_plateforme,
methode_remboursement, type_pret_defaut, freq_interets_defaut,
description, annee_creation, investisseurs_types,
regulateur, numero_licence, is_regule,
@@ -569,7 +626,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
garantie_rachat, statistiques_publiques, bonus_inscription, marche_secondaire, investissement_auto,
url_trustpilot, url_linkedin,
logo_filename, icone_filename, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
`).run(p.nom, ...fields);
refId = r.lastInsertRowid;
created++;
@@ -585,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) {
@@ -654,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); }
});
@@ -668,7 +730,7 @@ router.post('/', (req, res, next) => {
const r = db.prepare(`
INSERT INTO plateformes_referentiel
(nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal,
(nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, categorie_plateforme, frais_plateforme,
methode_remboursement, type_pret_defaut, freq_interets_defaut,
logo_filename, description,
annee_creation, investisseurs_types,
@@ -680,7 +742,7 @@ router.post('/', (req, res, next) => {
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
`).run(
data.nom, data.url || null, data.domiciliation, data.fiscalite,
data.taux_fiscalite_locale ?? null, data.type_produit_fiscal,
data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, data.categorie_plateforme, data.frais_plateforme,
data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null,
data.logo_filename ?? null, data.description ?? null,
data.annee_creation ?? null, data.investisseurs_types ?? null,
@@ -696,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); }
@@ -722,7 +784,7 @@ router.put('/:id', (req, res, next) => {
db.prepare(`
UPDATE plateformes_referentiel
SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?,
type_produit_fiscal=?, categorie_plateforme=?, frais_plateforme=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?,
logo_filename=?, description=?,
annee_creation=?, investisseurs_types=?,
regulateur=?, numero_licence=?, is_regule=?,
@@ -735,7 +797,7 @@ router.put('/:id', (req, res, next) => {
WHERE id=?
`).run(
data.nom, data.url || null, data.domiciliation, data.fiscalite,
data.taux_fiscalite_locale ?? null, data.type_produit_fiscal,
data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, data.categorie_plateforme, data.frais_plateforme,
data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null,
data.logo_filename ?? null, data.description ?? null,
data.annee_creation ?? null, data.investisseurs_types ?? null,
@@ -750,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); }
@@ -814,7 +876,7 @@ router.post('/:id/push', (req, res, next) => {
const force = req.body?.force === true;
// Champs scalaires poussables
const PUSHABLE = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut'];
const PUSHABLE = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'categorie_plateforme', 'frais_plateforme', 'logo_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut'];
// Catégories/secteurs du référentiel
const refCatIds = db.prepare(
@@ -0,0 +1,159 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/regroupementsPatrimoniaux.js — Référentiel admin des "regroupements patrimoniaux"
* (chantier du 28/09/26 (suite), demande Olivier — présenter une vue patrimoine consolidée par
* grandes familles d'actifs : Immobilier, Comptes courants, Comptes d'épargne, Crypto, Comptes
* d'investissement, Crowdlending, Autres actifs — et un regroupement "Passif" pour les emprunts).
*
* Orthogonal à categories_enveloppes : les catégories organisent le catalogue admin, ce
* référentiel sert à présenter une vue consolidée (une même catégorie, ex. "Financement
* participatif / private equity", peut donc se scinder entre plusieurs regroupements ici).
*
* Hiérarchie générique à deux niveaux (parent_id auto-référencé) — cadrage validé par Olivier
* via AskUserQuestion (28/09/26 (suite)) : réutilisable pour d'autres sous-catégories futures,
* pas un cas particulier de "Private Equity" (aujourd'hui seul enfant existant, rattaché à
* "Comptes d'investissement").
*
* Rattaché par enveloppes_referentiel.regroupement_patrimonial_id (catalogue, exclusif) et
* emprunts.regroupement_patrimonial_id (toujours "Passif" pour l'instant). Voir
* claude/plan_regroupements_patrimoniaux.md (doc projet) pour le cadrage complet et les
* décisions prises avec Olivier.
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
parent_id: z.number().int().positive().nullable().optional(),
type: z.enum(['actif', 'passif']).optional().default('actif'),
ordre_affichage: z.number().int().optional().default(0),
});
function withCounts(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const placeholders = ids.map(() => '?').join(',');
const nbEnveloppes = db.prepare(`
SELECT regroupement_patrimonial_id AS id, COUNT(*) AS n
FROM enveloppes_referentiel
WHERE regroupement_patrimonial_id IN (${placeholders})
GROUP BY regroupement_patrimonial_id
`).all(...ids);
const nbEmprunts = db.prepare(`
SELECT regroupement_patrimonial_id AS id, COUNT(*) AS n
FROM emprunts
WHERE regroupement_patrimonial_id IN (${placeholders})
GROUP BY regroupement_patrimonial_id
`).all(...ids);
const nbEnfants = db.prepare(`
SELECT parent_id AS id, COUNT(*) AS n
FROM regroupements_patrimoniaux
WHERE parent_id IN (${placeholders})
GROUP BY parent_id
`).all(...ids);
const mapOf = list => Object.fromEntries(list.map(r => [r.id, r.n]));
const mEnv = mapOf(nbEnveloppes), mEmp = mapOf(nbEmprunts), mEnf = mapOf(nbEnfants);
return rows.map(r => ({
...r,
nb_enveloppes: mEnv[r.id] || 0,
nb_emprunts: mEmp[r.id] || 0,
nb_enfants: mEnf[r.id] || 0,
}));
}
/* GET /api/regroupements-patrimoniaux — liste complète, avec nom du parent */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT r.id, r.nom, r.parent_id, r.type, r.ordre_affichage, r.created_at, r.updated_at,
p.nom AS parent_nom
FROM regroupements_patrimoniaux r
LEFT JOIN regroupements_patrimoniaux p ON p.id = r.parent_id
ORDER BY r.ordre_affichage, r.nom
`).all();
res.json(withCounts(rows));
});
/* POST /api/regroupements-patrimoniaux — créer */
router.post('/', (req, res, next) => {
try {
const data = Schema.parse(req.body);
const existing = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE nom = ?').get(data.nom);
if (existing) throw new HttpError(409, `Un regroupement "${data.nom}" existe déjà`);
let parent = null;
if (data.parent_id) {
parent = db.prepare('SELECT id, type FROM regroupements_patrimoniaux WHERE id = ?').get(data.parent_id);
if (!parent) throw new HttpError(400, 'Regroupement parent introuvable');
if (parent.parent_id) throw new HttpError(400, 'Un regroupement parent ne peut pas lui-même avoir un parent (hiérarchie à 2 niveaux maximum)');
}
// Le type d'un enfant suit toujours celui de son parent (pas de mélange actif/passif dans
// une même branche).
const type = parent ? parent.type : data.type;
const r = db.prepare(`
INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage, updated_at)
VALUES (?,?,?,?, datetime('now'))
`).run(data.nom, data.parent_id ?? null, type, data.ordre_affichage);
const row = db.prepare('SELECT * FROM regroupements_patrimoniaux WHERE id = ?').get(r.lastInsertRowid);
res.status(201).json(withCounts([row])[0]);
} catch (e) { next(e); }
});
/* PUT /api/regroupements-patrimoniaux/:id — modifier */
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM regroupements_patrimoniaux WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Regroupement introuvable');
const data = Schema.parse(req.body);
if (data.nom !== row.nom) {
const dup = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE nom = ? AND id != ?').get(data.nom, row.id);
if (dup) throw new HttpError(409, `Un regroupement "${data.nom}" existe déjà`);
}
if (data.parent_id === row.id) throw new HttpError(400, 'Un regroupement ne peut pas être son propre parent');
let parent = null;
if (data.parent_id) {
parent = db.prepare('SELECT id, parent_id, type FROM regroupements_patrimoniaux WHERE id = ?').get(data.parent_id);
if (!parent) throw new HttpError(400, 'Regroupement parent introuvable');
if (parent.parent_id) throw new HttpError(400, 'Un regroupement parent ne peut pas lui-même avoir un parent (hiérarchie à 2 niveaux maximum)');
}
// Empêche de donner un parent à un regroupement qui a lui-même déjà des enfants.
if (data.parent_id) {
const nbEnfants = db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux WHERE parent_id = ?').get(row.id).n;
if (nbEnfants > 0) throw new HttpError(400, 'Ce regroupement a déjà des sous-regroupements — il ne peut pas devenir lui-même un enfant.');
}
const type = parent ? parent.type : data.type;
db.prepare(`
UPDATE regroupements_patrimoniaux
SET nom=?, parent_id=?, type=?, ordre_affichage=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.parent_id ?? null, type, data.ordre_affichage, row.id);
const updated = db.prepare('SELECT * FROM regroupements_patrimoniaux WHERE id = ?').get(row.id);
res.json(withCounts([updated])[0]);
} catch (e) { next(e); }
});
/* DELETE /api/regroupements-patrimoniaux/:id — supprimer si non utilisé et sans enfant */
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Regroupement introuvable');
const [withCount] = withCounts([row]);
if (withCount.nb_enfants > 0) throw new HttpError(409, `Ce regroupement a ${withCount.nb_enfants} sous-regroupement(s) — supprimez-les d'abord.`);
if (withCount.nb_enveloppes > 0) throw new HttpError(409, `Regroupement utilisé par ${withCount.nb_enveloppes} enveloppe(s) — retirez-le d'abord.`);
if (withCount.nb_emprunts > 0) throw new HttpError(409, `Regroupement utilisé par ${withCount.nb_emprunts} emprunt(s) — retirez-le d'abord.`);
db.prepare('DELETE FROM regroupements_patrimoniaux WHERE id = ?').run(row.id);
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
@@ -0,0 +1,30 @@
import { Router } from 'express';
import db from '../db/index.js';
/**
* routes/regroupementsPatrimoniauxPublic.js — lecture seule de la hiérarchie des
* "regroupements patrimoniaux" pour tous les utilisateurs authentifiés (pas requireAdmin), sur
* le même modèle que enveloppesReferentielPublic.js/institutionsReferentielPublic.js.
*
* Chantier "Compléter mon patrimoine" (30/09/26, demande Olivier) : la modale d'ajout rapide
* (bouton "+ Compléter mon patrimoine" de la topbar, cf. Layout.jsx/PatrimoineAjoutModal.jsx)
* a besoin de la liste complète des regroupements (racines + enfants) pour construire son
* premier écran (catégories) et déterminer, au second écran, quelles enveloppes rattacher à
* quelle catégorie cliquée (y compris ses éventuels enfants) — or regroupementsPatrimoniaux.js
* (CRUD admin) est monté sous requireAdmin, inaccessible à un utilisateur normal.
*/
const router = Router();
// Monté sous requireAuth (sans requireAdmin) — lecture seule, aucune route d'écriture ici.
/* GET /api/regroupements-patrimoniaux-public — liste complète (lecture seule) */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT id, nom, parent_id, type, ordre_affichage
FROM regroupements_patrimoniaux
ORDER BY ordre_affichage, nom
`).all();
res.json(rows);
});
export default router;
+165 -38
View File
@@ -3,7 +3,9 @@ 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';
const router = Router();
@@ -12,12 +14,36 @@ 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(),
// Nature de la distribution pour un deal PE (18/09/26, demande Olivier ; étendue le 20/09/26,
// chantier "Fiscalité PE") : 'capital' = restitution du capital déployé (non imposable),
// 'interet_non_deploye' = intérêt versé sur le capital NON déployé (support obligataire du
// fonds, imposable comme un revenu), 'plus_value' = plus-value réalisée distribuée par le
// fonds au fil de ses propres cessions (imposable, mais reportée dans une case différente de
// 'interet_non_deploye' sur la déclaration — cf. taxreportPe.js) — cf. migration
// distribution_nature dans db/index.js. Optionnel : une distribution non classée reste comptée
// dans "Distributions" (pas "Obligation"/"Plus-value") côté PE. Sans effet hors type
// 'interets_plateforme'.
distribution_nature: z.enum(['capital', 'interet_non_deploye', 'plus_value']).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),
interets_bruts_avant_local: z.number().nonnegative().default(0),
taxe_locale: z.number().nonnegative().default(0),
interets_bruts: z.number().nonnegative().default(0),
frais_ttc: z.number().nonnegative().default(0),
// Prélèvement du frais (Étape 5 bis, 01/09/26 — cas Baltis remonté par Olivier) : par défaut
// 'remboursement' (comportement historique, Étape 4) = le frais est retenu SUR ce versement
// (réduit la base imposable ET net_recu) ; 'portefeuille'/'compte_courant' = le frais est réglé
// à part (comme pour la souscription) — aucun impact sur l'imposition ni le montant versé de CE
// remboursement, cf. computeChamps() plus bas. cf. project_frais_operation.md.
frais_mode_reglement: z.enum(['remboursement', 'portefeuille', 'compte_courant']).default('remboursement'),
frais_compte_id: z.number().int().positive().nullable().optional(),
prelev_sociaux: z.number().nonnegative().default(0),
prelev_forfaitaire: z.number().nonnegative().default(0),
statut: z.enum(['paye','retard','partiel','impaye']).default('paye'),
@@ -37,9 +63,20 @@ const Schema = z.object({
* les prélèvements sont retenus à la source, net_recu = capital + cashback + interets_nets.
*/
function computeChamps(body, isTaxIndicatif = false) {
const interets_nets = Math.round((body.interets_bruts - body.prelev_sociaux - body.prelev_forfaitaire) * 100) / 100;
// Le frais ne réduit la base imposable (et donc net_recu) que lorsqu'il est réellement
// RETENU SUR ce remboursement (mode 'remboursement', comportement historique de l'Étape 4).
// Un frais réglé à part ('portefeuille'/'compte_courant', Étape 5 bis, 01/09/26 — cas Baltis)
// n'a aucun impact ici : la plateforme calcule ses prélèvements sur l'intérêt brut complet, et
// le frais est une sortie d'argent séparée, tracée dans frais_operations mais sans effet sur
// l'imposition ou le montant versé de CE remboursement — cf. project_frais_operation.md.
const fraisReduitBase = !body.frais_mode_reglement || body.frais_mode_reglement === 'remboursement';
const fraisTtc = fraisReduitBase ? (body.frais_ttc || 0) : 0;
// Base imposable nette de frais : imposition = f(interets_bruts - frais_ttc),
// cf. avis d'opéré / notice de distribution Kyoseil-AM (Fundora) — Olivier, 30/08/26.
const baseNetteDeFrais = Math.round(((body.interets_bruts || 0) - fraisTtc) * 100) / 100;
const interets_nets = Math.round((baseNetteDeFrais - body.prelev_sociaux - body.prelev_forfaitaire) * 100) / 100;
const net_recu = isTaxIndicatif
? Math.round(((body.capital || 0) + (body.cashback || 0) + (body.interets_bruts || 0)) * 100) / 100
? Math.round(((body.capital || 0) + (body.cashback || 0) + baseNetteDeFrais) * 100) / 100
: Math.round(((body.capital || 0) + (body.cashback || 0) + interets_nets) * 100) / 100;
return { interets_nets, net_recu };
}
@@ -69,7 +106,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);
@@ -80,19 +117,67 @@ 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,
);
}
/**
* Synchronise la ligne frais_operations (catégorie 'distribution') liée à CE remboursement
* avec son champ frais_ttc — Étape 4, 31/08/26. Depuis l'Étape 5 bis (01/09/26, cas Baltis
* remonté par Olivier), le mode_reglement de cette ligne n'est plus figé à 'remboursement' :
* l'utilisateur choisit, comme pour le frais de souscription, si le frais est retenu SUR ce
* remboursement ('remboursement' — déjà intégralement pris en compte dans net_recu, cf.
* computeChamps, donc EXCLU du calcul du solde du porte-monnaie dans dashboard.js pour ne pas
* le déduire deux fois) ou réglé à part ('portefeuille'/'compte_courant' — jamais netté nulle
* part ailleurs, donc dûment compté dans fraisPerPlat/le XIRR net comme n'importe quel autre
* frais de ce mode, cf. project_frais_operation.md).
* Upsert par remboursement_id (relation 1:1, quel que soit le mode) ; suppression si le montant
* est vidé. La suppression du remboursement lui-même cascade nativement (FK ON DELETE CASCADE).
* Uniquement pour les remboursements de type 'normal' (seuls à porter un investissement_id).
*/
function syncFraisDistribution(rembId, investissementId, plateformeId, fraisTtc, dateRemb, modeReglement = 'remboursement', compteId = null) {
const existing = db.prepare(
`SELECT id FROM frais_operations WHERE remboursement_id = ?`
).get(rembId);
if (!fraisTtc || fraisTtc <= 0) {
if (existing) db.prepare('DELETE FROM frais_operations WHERE id = ?').run(existing.id);
return;
}
const compte = modeReglement === 'compte_courant' ? (compteId ?? null) : null;
if (existing) {
db.prepare(`
UPDATE frais_operations
SET investissement_id = ?, plateforme_id = ?, montant = ?, mode_reglement = ?, compte_id = ?,
date_operation = ?, updated_at = datetime('now')
WHERE id = ?
`).run(investissementId, plateformeId, fraisTtc, modeReglement, compte, dateRemb, existing.id);
} else {
db.prepare(`
INSERT INTO frais_operations
(investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, 'remboursement', 'distribution', NULL)
`).run(investissementId, plateformeId, rembId, fraisTtc, modeReglement, compte, dateRemb);
}
}
export function syncInvestissementStatut(investissement_id) {
if (!investissement_id) return;
const inv = db.prepare('SELECT montant_investi FROM investissements WHERE id=?').get(investissement_id);
@@ -139,6 +224,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 = [];
@@ -166,7 +254,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.*,
@@ -180,7 +268,11 @@ router.get('/', (req, res) => {
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
plat_inv.nom AS plateforme_detenteur_nom,
c.nom AS compte_nom
c.nom AS compte_nom,
-- Sous-requête scalaire plutôt qu'un LEFT JOIN : frais_operations porte elle aussi des
-- colonnes investissement_id/plateforme_id, un JOIN les rendrait ambiguës pour outerWhere
-- (qui référence ces noms de colonnes sans préfixe de table) — Étape 5 bis, 01/09/26.
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = b.id) AS frais_mode_reglement
FROM base b
LEFT JOIN plateformes p ON p.id = b.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
@@ -188,7 +280,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);
});
@@ -200,22 +292,31 @@ router.post('/', (req, res, next) => {
if (body.type === 'normal') {
assertOwnedInvestissement(body.investissement_id, req.user.id);
if (body.frais_mode_reglement === 'compte_courant') assertOwnedCompte(body.frais_compte_id, req.user.id);
const r = db.prepare(`
INSERT INTO remboursements
(type, investissement_id, date_remb, capital, cashback,
interets_bruts_avant_local, taxe_locale, interets_bruts, prelev_sociaux,
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id)
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?)
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, 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.prelev_sociaux, body.prelev_forfaitaire,
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(
r.lastInsertRowid, body.investissement_id, invPlat?.plateforme_id ?? null, body.frais_ttc, body.date_remb,
body.frais_mode_reglement, body.frais_mode_reglement === 'compte_courant' ? body.frais_compte_id : null,
);
}
// ── Réinvestissement automatique des intérêts ──────────────────────────
{
const inv = db.prepare(
@@ -245,14 +346,16 @@ router.post('/', (req, res, next) => {
const r = db.prepare(`
INSERT INTO remboursements
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes)
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?, ?,?, ?, 'manuel', ?)
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id,
investissement_pe_id, distribution_nature)
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.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, body.distribution_nature ?? null,
);
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
}
@@ -261,11 +364,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); }
@@ -278,9 +382,13 @@ router.post('/reprocess', (req, res, next) => {
const rembs = db.prepare(`
SELECT r.id, r.investissement_id, r.capital, r.cashback, r.date_remb,
r.interets_bruts_avant_local, r.taxe_locale, r.interets_bruts,
r.interets_bruts_avant_local, r.taxe_locale, r.interets_bruts, r.frais_ttc,
p.fiscalite, p.taux_fiscalite_locale,
i.fiscalite_override
i.fiscalite_override,
-- Étape 5 bis, 01/09/26 : le frais n'est pas toujours réglé SUR ce remboursement
-- (cf. computeChamps/syncFraisDistribution) — sous-requête scalaire pour la même
-- raison qu'au GET / ci-dessus (éviter l'ambiguïté investissement_id/plateforme_id).
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
@@ -302,6 +410,8 @@ router.post('/reprocess', (req, res, next) => {
interets_nets=?, net_recu=?
WHERE id=?
`);
// frais_ttc n'est pas recalculé ici (montant saisi manuellement, pas dérivé
// d'un taux) — seule la base imposable (interets_bruts - frais_ttc) en tient compte.
const stmtUpdateRetrait = db.prepare(`
UPDATE depots_retraits SET montant=?
@@ -328,18 +438,25 @@ router.post('/reprocess', (req, res, next) => {
interets_bruts = round2(base - taxe_locale);
}
// Base imposable nette de frais : imposition = f(interets_bruts - frais_ttc), MAIS
// uniquement quand le frais est retenu SUR ce remboursement — cf. computeChamps()
// ci-dessus (Étape 5 bis, 01/09/26) et l'avis d'opéré Kyoseil-AM (Fundora), 30/08/26.
const fraisReduitBase = !r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement';
const fraisTtc = fraisReduitBase ? (r.frais_ttc || 0) : 0;
const baseNetteDeFrais = round2(interets_bruts - fraisTtc);
// Prélèvements calculés pour TOUTES les plateformes (indicatif pour les non-flat_tax)
if (rates) {
prelev_sociaux = round2(interets_bruts * rates.prelev_sociaux / 100);
prelev_forfaitaire = round2(interets_bruts * rates.impot_revenu / 100);
prelev_sociaux = round2(baseNetteDeFrais * rates.prelev_sociaux / 100);
prelev_forfaitaire = round2(baseNetteDeFrais * rates.impot_revenu / 100);
}
const interets_nets = round2(interets_bruts - prelev_sociaux - prelev_forfaitaire);
const interets_nets = round2(baseNetteDeFrais - prelev_sociaux - prelev_forfaitaire);
// Plateforme étrangère ou investissement exonéré : le montant versé n'inclut pas
// les prélèvements indicatifs — l'investisseur reçoit le brut intégral.
// les prélèvements indicatifs — l'investisseur reçoit le brut (net de frais) intégral.
const isTaxIndicatif = r.fiscalite !== 'flat_tax' || r.fiscalite_override === 'exonere';
const net_recu = isTaxIndicatif
? round2((r.capital || 0) + (r.cashback || 0) + interets_bruts)
? round2((r.capital || 0) + (r.cashback || 0) + baseNetteDeFrais)
: round2((r.capital || 0) + (r.cashback || 0) + interets_nets);
stmtUpdate.run(taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire,
@@ -376,16 +493,17 @@ router.put('/:id', (req, res, next) => {
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body));
if (body.type === 'normal') {
if (body.frais_mode_reglement === 'compte_courant') assertOwnedCompte(body.frais_compte_id, req.user.id);
db.prepare(`
UPDATE remboursements
SET date_remb=?, capital=?, cashback=?,
interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?,
interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
prelev_sociaux=?, prelev_forfaitaire=?,
interets_nets=?, net_recu=?, statut=?, notes=?, methode_remboursement=?, compte_id=?
WHERE id=?
`).run(
body.date_remb, body.capital, body.cashback,
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
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,
@@ -393,25 +511,34 @@ 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(
id, body.investissement_id, invPlat?.plateforme_id ?? null, body.frais_ttc, body.date_remb,
body.frais_mode_reglement, body.frais_mode_reglement === 'compte_courant' ? body.frais_compte_id : null,
);
}
} else if (body.type === 'interets_plateforme') {
db.prepare(`
UPDATE remboursements
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?,
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
investissement_pe_id=?, distribution_nature=?
WHERE id=?
`).run(
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
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,
body.distribution_nature ?? 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 });
@@ -431,7 +558,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);
@@ -450,7 +577,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);
@@ -485,7 +612,7 @@ router.post('/backfill-comptes', (req, res, next) => {
// Fallback : premier compte_courant du détenteur
if (!compteId) {
const compte = db.prepare(
"SELECT id FROM comptes WHERE investisseur_id=? AND user_id=? AND type='compte_courant' ORDER BY id LIMIT 1"
"SELECT id FROM comptes WHERE investisseur_id=? AND user_id=? AND compte_reglement_par_defaut=1 ORDER BY id LIMIT 1"
).get(row.investisseur_id, req.user.id);
compteId = compte?.id ?? null;
}
+102
View File
@@ -0,0 +1,102 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/supportsReferentiel.js — Référentiel admin des supports d'investissement (fonds
* euros, ETF, SCPI, titres vifs...) — chantier "Référentiel Enveloppes & Supports d'épargne"
* (28/09/26, demande Olivier).
*
* Catalogue à plat (pas de sous-catégories — une dizaine d'entrées seulement, cf. "Types de
* supports (UC) disponibles" dans la liste fournie par Olivier), transverse à tous les espaces
* de travail comme les institutions financières. Consommé par enveloppesReferentiel.js pour la
* relation N-N enveloppe ↔ supports disponibles (enveloppes_supports_disponibles).
*
* Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion le 28/09/26) —
* aucun rattachement à l'existant (comptes, investissements...), en préparation du futur module
* patrimoine (cf. plan de déploiement, doc projet).
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
description: z.string().nullable().optional(),
});
// ── GET /api/supports-referentiel ───────────────────────────────────────────
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT s.*, COUNT(esd.enveloppe_id) AS nb_enveloppes
FROM supports_referentiel s
LEFT JOIN enveloppes_supports_disponibles esd ON esd.support_id = s.id
GROUP BY s.id
ORDER BY s.nom
`).all();
res.json(rows);
});
// ── GET /api/supports-referentiel/:id ───────────────────────────────────────
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
res.json(row);
} catch (e) { next(e); }
});
// ── POST /api/supports-referentiel ──────────────────────────────────────────
router.post('/', (req, res, next) => {
try {
const data = Schema.parse(req.body);
const existing = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?').get(data.nom);
if (existing) throw new HttpError(409, `Un support "${data.nom}" existe déjà`);
const r = db.prepare(`
INSERT INTO supports_referentiel (nom, description, updated_at)
VALUES (?, ?, datetime('now'))
`).run(data.nom, data.description || null);
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(r.lastInsertRowid);
res.status(201).json(row);
} catch (e) { next(e); }
});
// ── PUT /api/supports-referentiel/:id ───────────────────────────────────────
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
const data = Schema.parse(req.body);
if (data.nom !== row.nom) {
const dup = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id);
if (dup) throw new HttpError(409, `Un support "${data.nom}" existe déjà`);
}
db.prepare(`
UPDATE supports_referentiel
SET nom=?, description=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.description || null, row.id);
const updated = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(row.id);
res.json(updated);
} catch (e) { next(e); }
});
// ── DELETE /api/supports-referentiel/:id ────────────────────────────────────
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
// enveloppes_supports_disponibles a ON DELETE CASCADE sur support_id : les liens sont
// nettoyés automatiquement, aucune purge manuelle nécessaire ici.
db.prepare('DELETE FROM supports_referentiel WHERE id = ?').run(row.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
+180 -3
View File
@@ -117,6 +117,15 @@ router.get('/', (req, res) => {
const interetsNets = recap.interets_bruts - recap.prelev_sociaux - recap.prelev_forfaitaire;
// Au moins une plateforme PSAN (crypto) configurée, quelle que soit sa domiciliation — pour
// exposer cerfa2042cpro (case 5HQ, micro-BNC) au frontend. Même logique que v1/taxreport.js
// (Olivier 30/08/26) : ce handler interne '/' alimente le state `data` de TaxReport.jsx, donc
// il doit porter ce champ lui aussi (l'agrégat /api/v1/taxreport est un endpoint distinct).
const hasPsan = !!db.prepare(
`SELECT 1 FROM plateformes WHERE user_id = ? AND categorie_plateforme = 'psan' LIMIT 1`
).get(req.user.id);
const cerfa2042cpro = hasPsan ? build2042CPro(annee, invCond, invCondBonus, invArg) : null;
const recapTotal = {
interets_bruts: round2(recap.interets_bruts),
prelev_sociaux: round2(recap.prelev_sociaux),
@@ -135,7 +144,7 @@ router.get('/', (req, res) => {
note: "Les cases sont indicatives. Verifiez avec votre situation fiscale reelle.",
};
res.json({ annee, recap: recapTotal, pertes, pertesTotales: round2(pertesTotales), detail, corrections, cases });
res.json({ annee, recap: recapTotal, pertes, pertesTotales: round2(pertesTotales), detail, corrections, cases, has_psan: hasPsan, cerfa2042cpro });
});
router.get('/export', (req, res) => {
@@ -512,8 +521,13 @@ router.get('/years', (req, res) => {
// Filtre commun 2047/2778-SD : seules les plateformes étrangères (domiciliation != FR)
// ET de catégorie "Prestataire de Services de Financement Participatif" (PSFP) sont
// concernées par ces CERFA. Les plateformes "Prestataire de Services sur Actifs
// Numériques" (PSAN) sont exclues même si domiciliées hors de France (demande Olivier
// 30/08/26).
function build2778(annee, invCond, invCondBonus2778, invArg) {
// Plateformes étrangères × investisseur avec au moins un remboursement sur l'année
// Plateformes étrangères PSFP × investisseur avec au moins un remboursement sur l'année
const platRows = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
FROM remboursements r
@@ -525,6 +539,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) {
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
ORDER BY p.nom, inv.nom
`).all(invArg, annee);
@@ -540,6 +555,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) {
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
`).all(invArg, annee);
const platRowsMap = {};
@@ -569,6 +585,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) {
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
GROUP BY p.id, i.investisseur_id, mois
`).all(invArg, annee);
@@ -590,6 +607,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) {
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
GROUP BY p.id, r.bonus_investisseur_id, mois
`).all(invArg, annee);
@@ -636,6 +654,7 @@ router.get('/2778', (req, res) => {
res.json(build2778(annee, invCond, invCondBonus2778, invArg));
});
// cf. commentaire au-dessus de build2778 : même filtre PSFP.
function build2047(annee, invCond, invCondBonus2047, invArg) {
const rows = db.prepare(`
SELECT
@@ -657,6 +676,7 @@ function build2047(annee, invCond, invCondBonus2047, invArg) {
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
GROUP BY p.id, inv.id
`).all(invArg, annee);
@@ -679,6 +699,7 @@ function build2047(annee, invCond, invCondBonus2047, invArg) {
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
GROUP BY p.id, inv.id
`).all(invArg, annee);
@@ -793,6 +814,162 @@ function build2047(annee, invCond, invCondBonus2047, invArg) {
Éligibilité = convention active + taux d'intérêt renseigné + non "exclusif résidence"
(le pictogramme c/ de la notice signifie imposable exclusivement en France : aucun
crédit d'impôt possible, même si la plateforme a réellement prélevé une taxe locale). ── */
/* ── Filtre commun 2042 C PRO (PSAN) : plateformes de catégorie "Prestataire de Services sur
Actifs Numériques" (crypto), quelle que soit leur domiciliation (FR ou étrangère) — contrairement
à 2047/2778-SD (PSFP étrangères uniquement), la doctrine BNC/micro-BNC pour les intérêts/rewards
crypto (staking) s'applique en fonction de la nature du revenu, pas de la localisation de la
plateforme. Pas de crédit d'impôt conventionnel ici (pas de mécanisme 2047) : les recettes sont
déclarées directement en 2042-C PRO, case 5HQ (régime micro-BNC, abattement forfaitaire de 34 %
appliqué automatiquement par l'administration). Demande Olivier 30/08/26. ── */
function build2042CPro(annee, invCond, invCondBonus2042cpro, invArg) {
// Plateformes PSAN × investisseur avec au moins un remboursement sur l'année
const platRows = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.categorie_plateforme = 'psan'
ORDER BY p.nom, inv.nom
`).all(invArg, annee);
// Intérêts plateforme (pas d'investissement) sur des plateformes PSAN — même logique de
// fusion que pour le 2778/2047 : rattachés à la ligne plateforme × investisseur.
const platRowsInteretsPlateforme = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
WHERE ${invCondBonus2042cpro}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.categorie_plateforme = 'psan'
`).all(invArg, annee);
const platRowsMap = {};
for (const p of [...platRows, ...platRowsInteretsPlateforme]) {
platRowsMap[`${p.id}_${p.investisseur_id}`] = p;
}
const platRowsMerged = Object.values(platRowsMap).sort((a, b) =>
a.nom.localeCompare(b.nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
// Montants mensuels par plateforme × investisseur — même base que 2047/2778 (intérêts bruts
// avant retenue locale) : il n'y a pas de mécanisme de crédit d'impôt pour la partie BNC/PSAN,
// mais la base de calcul de la recette brute reste cohérente avec le reste de l'app.
const moisRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(
CASE WHEN r.interets_bruts_avant_local IS NOT NULL AND r.interets_bruts_avant_local > 0
THEN r.interets_bruts_avant_local
ELSE r.interets_bruts
END
), 0) AS montant
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.categorie_plateforme = 'psan'
GROUP BY p.id, i.investisseur_id, mois
`).all(invArg, annee);
const moisRowsInteretsPlateforme = db.prepare(`
SELECT
p.id AS plateforme_id,
r.bonus_investisseur_id AS investisseur_id,
substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(
CASE WHEN r.interets_bruts_avant_local IS NOT NULL AND r.interets_bruts_avant_local > 0
THEN r.interets_bruts_avant_local
ELSE r.interets_bruts
END
), 0) AS montant
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invCondBonus2042cpro}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.categorie_plateforme = 'psan'
GROUP BY p.id, r.bonus_investisseur_id, mois
`).all(invArg, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = round2(m.montant);
}
for (const m of moisRowsInteretsPlateforme) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = round2((moisMap[key][m.mois] ?? 0) + m.montant);
}
const plateformes = platRowsMerged.map(p => ({
id: `${p.id}_${p.investisseur_id}`,
plateforme_id: p.id,
nom: p.nom,
investisseur_id: p.investisseur_id,
investisseur_nom: p.investisseur_nom,
investisseur_prenom: p.investisseur_prenom,
mois: moisMap[`${p.id}_${p.investisseur_id}`] ?? {},
}));
// Données 2042 C PRO — régime micro-BNC (case 5HQ) : les recettes brutes sont déclarées
// telles quelles, l'administration applique automatiquement l'abattement forfaitaire de 34 %
// pour frais (CGI art. 102 ter). Au-delà du seuil, le régime réel (cases 5KO/5KP) s'applique —
// affiché ici à titre indicatif seulement, l'app ne calcule pas le régime réel (charges réelles
// non suivies dans l'app).
const SEUIL_MICRO_BNC = 77700;
const ABATTEMENT_MICRO_BNC = 0.34;
const totalBrut = round2(plateformes.reduce(
(s, p) => s + Object.values(p.mois).reduce((a, b) => a + b, 0), 0
));
const case_5HQ = Math.round(totalBrut);
const montantImposable = Math.round(case_5HQ * (1 - ABATTEMENT_MICRO_BNC));
return {
annee,
plateformes,
totalBrut,
donnees2042cpro: {
case_5HQ,
abattement_taux: ABATTEMENT_MICRO_BNC,
montant_imposable: montantImposable,
seuil_micro_bnc: SEUIL_MICRO_BNC,
depasse_seuil_micro: case_5HQ > SEUIL_MICRO_BNC,
},
};
}
/* ── 2042 C PRO — Micro-BNC (case 5HQ) — plateformes PSAN (staking / rewards crypto) ──
Contrairement au 2047/2778-SD, ce n'est pas une déclaration de revenus de source étrangère :
les recettes crypto sont des BNC quelle que soit la localisation de la plateforme, reportées
directement en 2042-C PRO sans passer par le 2047 (pas de crédit d'impôt conventionnel). ── */
router.get('/2042cpro', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.query.scope === 'all';
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invCondBonus2042cpro = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
res.json(build2042CPro(annee, invCond, invCondBonus2042cpro, invArg));
});
router.get('/2047', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.query.scope === 'all';
@@ -806,5 +983,5 @@ router.get('/2047', (req, res) => {
res.json(build2047(annee, invCond, invCondBonus2047, invArg));
});
export { buildCerfa2561, build2778, build2047 };
export { buildCerfa2561, build2778, build2047, build2042CPro };
export default router;
+434
View File
@@ -0,0 +1,434 @@
import { Router } from 'express';
import db from '../db/index.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
const router = Router();
const round2 = v => Math.round((v ?? 0) * 100) / 100;
const esc = v => String(v ?? '').replace(/;/g, ',').replace(/[\r\n]+/g, ' ');
/**
* /api/taxreport-pe — Fiscalité Private Equity (chantier du 20/09/26, demande Olivier :
* "Nous n'avons pas fait la partie Fiscalité pour les PE").
*
* Contrairement au crowdlending (taxreport.js), où chaque remboursement porte déjà ses
* propres prélev_sociaux/prelev_forfaitaire calculés à partir de la fiscalité de la
* plateforme, la fiscalité PE dépend avant tout du **mode de détention** du deal
* (investissements_pe.mode_detention — direct / PEA-PME / assurance-vie), trois régimes
* fiscaux très différents entre eux (choix explicite d'Olivier : "j'ai besoin de
* différencier les 3"). Cette route agrège donc les mêmes montants que
* RemboursementsSimple.jsx / InvestissementPeDetail.jsx, mais groupés par mode_detention
* plutôt que par plateforme.
*
* Décisions de conception validées avec Olivier avant implémentation :
* - La plus-value réalisée n'est PAS un événement "cession" séparé : elle est portée par
* les distributions existantes (remboursements.distribution_nature = 'plus_value'),
* au même titre que 'capital' (restitution non imposable) et 'interet_non_deploye'
* (revenu imposable sur le capital non déployé) — cf. migration dans db/index.js.
* - Les pertes définitives (investissement_pe_pertes, cf. investissementsPe.js
* POST/DELETE /:id/perte-definitive) sont reportées par mode_detention mais jamais
* imputées automatiquement sur les plus-values : comme taxreport.js côté crowdlending,
* cette route reste indicative — c'est à l'utilisateur d'arbitrer l'imputation réelle
* sur sa déclaration.
* - direct : PFU (12,8 % IR + 17,2 % PS, taux annuels dans taux_pfu) appliqué séparément
* sur la plus-value réalisée (case 3VG, formulaire 2074) et sur l'intérêt non déployé
* (case 2TR, revenus de capitaux mobiliers) — même taux global, mais deux cases
* différentes sur la déclaration, donc jamais fusionnées dans un seul montant.
* - PEA-PME : seuls les prélèvements sociaux (17,2 %) sont calculés — l'impôt sur le
* revenu est nativement exonéré tant que les conditions du plan sont respectées
* (retrait après 5 ans). Pas de case précise : en pratique, l'établissement teneur du
* PEA-PME précompte lui-même les PS au retrait ; ce montant reste indicatif pour
* vérification.
* - assurance-vie : aucun taux n'est appliqué automatiquement — la fiscalité dépend de
* l'ancienneté du contrat et du cumul des primes versées (au-delà de 150 000 €),
* des éléments que l'application ne connaît pas. Seuls les montants bruts sont
* exposés, à traiter avec votre assureur/expert fiscal.
*
* Comme taxreport.js : "Les cases sont indicatives. Vérifiez avec votre situation fiscale
* réelle." — cette route effectue des calculs déterministes à partir de règles convenues
* avec Olivier, ce n'est pas un avis fiscal.
*/
const MODES = ['direct', 'pea_pme', 'assurance_vie'];
function resolveScope(req, res) {
const scopeAll = req.query.scope === 'all';
if (scopeAll) return { invCond: 'inv.user_id = ?', invArgs: [req.user.id] };
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) { res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); return null; }
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!row) { res.status(403).json({ error: 'Investisseur not found or not owned by user' }); return null; }
return { invCond: 'inv.id = ?', invArgs: [id] };
}
function emptyBucket() {
return {
plus_value_realisee: 0,
interet_non_deploye: 0,
capital_restitue: 0,
non_classe: 0,
pertes_definitives: 0,
frais_gestion: 0,
};
}
// GET /years — années disponibles pour le sélecteur (mêmes sources que GET /), même principe
// que GET /taxreport/years côté crowdlending.
router.get('/years', (req, res) => {
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT DISTINCT strftime('%Y', r.date_remb) AS annee
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ? AND r.type = 'interets_plateforme'
UNION
SELECT DISTINCT strftime('%Y', ip.date_effet) AS annee
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY annee DESC
`).all(...invArgs, workspaceId, ...invArgs, workspaceId);
const years = rows.map(r => r.annee).filter(Boolean);
const currentYear = new Date().getFullYear();
if (!years.includes(String(currentYear))) years.unshift(String(currentYear));
if (!years.includes(String(currentYear - 1))) years.push(String(currentYear - 1));
res.json([...new Set(years)].sort((a, b) => b.localeCompare(a)));
});
router.get('/', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
// Distributions (remboursements de type interets_plateforme, rattachées à un deal PE précis
// — seul cas où mode_detention est connu). Les distributions non rattachées à un deal
// (investissement_pe_id NULL) sont regroupées à part : leur régime fiscal est indéterminable.
const distribRows = db.prepare(`
SELECT ipe.mode_detention, r.distribution_nature, r.net_recu
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
`).all(...invArgs, workspaceId, annee);
const distribNonRattacheesRows = db.prepare(`
SELECT r.distribution_nature, r.net_recu
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND r.investissement_pe_id IS NULL
AND r.workspace_id = ?
`).all(...invArgs, annee, workspaceId);
// Pertes définitives par mode de détention.
const pertesRows = db.prepare(`
SELECT ipe.mode_detention, ip.montant_perte, ip.date_effet, ip.motif,
ipe.nom_deal, p.nom AS plateforme_nom
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
JOIN plateformes p ON p.id = ipe.plateforme_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(ip.date_effet,1,4) = ?
`).all(...invArgs, workspaceId, annee);
// Frais de gestion (informatifs — pas de déduction automatique appliquée, la déductibilité
// des frais de gestion PE dépend de cas particuliers hors du périmètre de ce calcul).
const fraisRows = db.prepare(`
SELECT ipe.mode_detention, f.montant
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 ${invCond}
AND ipe.workspace_id = ?
AND substr(f.date_operation,1,4) = ?
AND f.categorie = 'gestion'
`).all(...invArgs, workspaceId, annee);
const buckets = { direct: emptyBucket(), pea_pme: emptyBucket(), assurance_vie: emptyBucket() };
const nonRattachees = emptyBucket();
const addDistrib = (bucket, nature, montant) => {
if (nature === 'plus_value') bucket.plus_value_realisee += montant;
else if (nature === 'interet_non_deploye') bucket.interet_non_deploye += montant;
else if (nature === 'capital') bucket.capital_restitue += montant;
else bucket.non_classe += montant;
};
for (const r of distribRows) {
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
addDistrib(bucket, r.distribution_nature, r.net_recu || 0);
}
for (const r of distribNonRattacheesRows) {
addDistrib(nonRattachees, r.distribution_nature, r.net_recu || 0);
}
for (const r of pertesRows) {
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
bucket.pertes_definitives += r.montant_perte || 0;
}
for (const r of fraisRows) {
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
bucket.frais_gestion += r.montant || 0;
}
// Taux PFU de l'année (mêmes taux que le crowdlending, table partagée taux_pfu).
const pfuRates = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(Number(annee))
?? db.prepare('SELECT * FROM taux_pfu ORDER BY annee DESC LIMIT 1').get()
?? null;
const fiscal = {};
for (const mode of MODES) {
const b = buckets[mode];
if (mode === 'direct') {
fiscal[mode] = {
prelev_sociaux_plus_value: pfuRates ? round2(b.plus_value_realisee * pfuRates.prelev_sociaux / 100) : null,
prelev_forfaitaire_plus_value: pfuRates ? round2(b.plus_value_realisee * pfuRates.impot_revenu / 100) : null,
prelev_sociaux_interets: pfuRates ? round2(b.interet_non_deploye * pfuRates.prelev_sociaux / 100) : null,
prelev_forfaitaire_interets: pfuRates ? round2(b.interet_non_deploye * pfuRates.impot_revenu / 100) : null,
};
} else if (mode === 'pea_pme') {
const baseImposable = b.plus_value_realisee + b.interet_non_deploye;
fiscal[mode] = {
prelev_sociaux: pfuRates ? round2(baseImposable * pfuRates.prelev_sociaux / 100) : null,
note: "Impôt sur le revenu nativement exonéré si les conditions du PEA-PME sont respectées (retrait après 5 ans) — seuls les prélèvements sociaux sont indicatifs ici.",
};
} else {
fiscal[mode] = {
note: "Fiscalité dépendant de l'ancienneté du contrat et du cumul des primes versées — aucun taux n'est appliqué automatiquement. Vérifiez avec votre assureur ou votre expert fiscal.",
};
}
}
// Important (correction 20/09/26, après vérification des libellés IFU réels dans
// Cerfa2561Preview.jsx côté crowdlending) : une perte définitive sur un deal PE n'est PAS
// une "perte en capital sur prêt participatif" (case 2TY du 2042, régime spécifique à l'art.
// 125-00 A du CGI réservé aux prêts de financement participatif — cf. KS/2TY dans
// Cerfa2561Preview.jsx, jamais utilisé pour un produit "à revenu fixe" ordinaire). Un deal PE
// est une part de fonds (valeur mobilière), donc sa perte définitive est une MOINS-VALUE DE
// CESSION (cession à prix nul), qui relève du même formulaire 2074 que la plus-value réalisée
// (imputable uniquement sur des plus-values mobilières, pas sur le revenu global) — jamais de
// la famille "2TR / IFU". D'où case_3VH_direct (et non case_2TY_direct) ci-dessous.
const cases = {
case_3VG_direct: round2(buckets.direct.plus_value_realisee),
case_2TR_direct: round2(buckets.direct.interet_non_deploye),
case_3VH_direct: round2(buckets.direct.pertes_definitives),
pea_pme_base_imposable_ps: round2(buckets.pea_pme.plus_value_realisee + buckets.pea_pme.interet_non_deploye),
pea_pme_pertes: round2(buckets.pea_pme.pertes_definitives),
assurance_vie_brut: round2(buckets.assurance_vie.plus_value_realisee + buckets.assurance_vie.interet_non_deploye),
assurance_vie_pertes: round2(buckets.assurance_vie.pertes_definitives),
note: "Les cases sont indicatives. Vérifiez avec votre situation fiscale réelle.",
};
res.json({
annee,
par_mode_detention: {
direct: { ...round2Bucket(buckets.direct), fiscal: fiscal.direct },
pea_pme: { ...round2Bucket(buckets.pea_pme), fiscal: fiscal.pea_pme },
assurance_vie: { ...round2Bucket(buckets.assurance_vie), fiscal: fiscal.assurance_vie },
},
non_rattachees: round2Bucket(nonRattachees),
pertes_detail: pertesRows.map(r => ({ ...r, montant_perte: round2(r.montant_perte) })),
has_pfu_rates: !!pfuRates,
cases,
});
});
function round2Bucket(b) {
return {
plus_value_realisee: round2(b.plus_value_realisee),
interet_non_deploye: round2(b.interet_non_deploye),
capital_restitue: round2(b.capital_restitue),
non_classe: round2(b.non_classe),
pertes_definitives: round2(b.pertes_definitives),
frais_gestion: round2(b.frais_gestion),
};
}
router.get('/export', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT ipe.nom_deal, p.nom AS plateforme, ipe.mode_detention,
r.date_remb, r.distribution_nature, r.net_recu, r.statut
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(r.date_remb,1,4) = ?
AND r.type = 'interets_plateforme'
ORDER BY r.date_remb
`).all(...invArgs, workspaceId, annee);
const pertesRows = db.prepare(`
SELECT ipe.nom_deal, p.nom AS plateforme, ipe.mode_detention,
ip.date_effet AS date_remb, 'perte_definitive' AS distribution_nature,
ip.montant_perte AS net_recu, 'perte' AS statut
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(ip.date_effet,1,4) = ?
`).all(...invArgs, workspaceId, annee);
const header = ['Deal','Plateforme','Mode de detention','Date','Nature','Montant net','Statut'];
const allRows = [...rows, ...pertesRows].sort((a, b) => (a.date_remb || '').localeCompare(b.date_remb || ''));
const csv = [
header.join(';'),
...allRows.map(r => [
esc(r.nom_deal), esc(r.plateforme), esc(r.mode_detention),
r.date_remb, esc(r.distribution_nature), r.net_recu, r.statut,
].join(';')),
].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="fiscalite-pe-${annee}.csv"`);
res.send('\uFEFF' + csv);
});
// ── CERFA 2042 / 2561 (IFU) — deals PE en détention directe ────────────────────────────────
//
// Demande d'Olivier (20/09/26) : reprendre, pour les deals PE en mode de détention 'direct'
// (compte-titres, seul régime où un PFU complet s'applique — cf. RegimeCard 'direct' dans
// TaxReportPe.jsx), le même principe que les onglets "CERFA 2042" et "CERFA 2561 (IFU)"
// crowdlending (Cerfa2042Preview.jsx / Cerfa2561Preview.jsx, taxreport.js::buildCerfa2561) :
// un suivi mensuel par plateforme × détenteur, et un mockup des cases de l'IFU réel.
//
// Différence structurelle importante avec le crowdlending (cf. recherche du 20/09/26 dans
// Cerfa2561Preview.jsx, aucune case "cessions de valeurs mobilières" n'y existe) : l'IFU ne
// couvre que les "produits de placement à revenu fixe" (case AR → 2TR), jamais les plus-values
// de cession. Une distribution PE de nature 'interet_non_deploye' relève donc de cette même
// case AR/2TR (jamais KR/2TT, réservée aux prêts de financement participatif — un deal PE est
// une part de fonds, pas un prêt) ; une distribution de nature 'plus_value' est HORS PÉRIMÈTRE
// IFU (formulaire 2074 → case 3VG du 2042), affichée séparément plutôt que mélangée à la case
// 2TR. Une perte définitive PE est, de la même façon, une moins-value de cession (formulaire
// 2074 → case 3VH), jamais la case 2TY (spécifique aux prêts participatifs, cf. KS/2TY dans
// Cerfa2561Preview.jsx) — cf. commentaire sur cases.case_3VH_direct plus haut dans ce fichier.
router.get('/cerfa2561', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT
p.id AS plateforme_id,
p.nom AS plateforme_nom,
inv.id AS investisseur_id,
inv.nom AS investisseur_nom,
inv.prenom AS investisseur_prenom,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.net_recu ELSE 0 END), 0) AS interets_bruts,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.prelev_sociaux ELSE 0 END), 0) AS prelev_sociaux,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.prelev_forfaitaire ELSE 0 END), 0) AS prelev_forfaitaire,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.net_recu ELSE 0 END), 0) AS plus_values,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.prelev_sociaux ELSE 0 END), 0) AS pv_prelev_sociaux,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.prelev_forfaitaire ELSE 0 END), 0) AS pv_prelev_forfaitaire
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND ipe.mode_detention = 'direct'
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
GROUP BY p.id, inv.id
ORDER BY p.nom, inv.nom
`).all(...invArgs, workspaceId, annee);
const pertesRows = db.prepare(`
SELECT p.id AS plateforme_id, ipe.investisseur_id,
COALESCE(SUM(ip.montant_perte), 0) AS perte
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND ipe.mode_detention = 'direct'
AND substr(ip.date_effet,1,4) = ?
GROUP BY p.id, ipe.investisseur_id
`).all(...invArgs, workspaceId, annee);
const pertesMap = {};
for (const p of pertesRows) pertesMap[`${p.plateforme_id}_${p.investisseur_id}`] = p.perte;
const lignes = rows.map(r => {
const key = `${r.plateforme_id}_${r.investisseur_id}`;
return {
plateforme_id: r.plateforme_id,
plateforme_nom: r.plateforme_nom,
investisseur_id: r.investisseur_id,
investisseur_nom: r.investisseur_nom,
investisseur_prenom: r.investisseur_prenom,
interets_bruts: round2(r.interets_bruts),
prelev_sociaux: round2(r.prelev_sociaux),
prelev_forfaitaire: round2(r.prelev_forfaitaire),
plus_values: round2(r.plus_values),
pv_prelev_sociaux: round2(r.pv_prelev_sociaux),
pv_prelev_forfaitaire: round2(r.pv_prelev_forfaitaire),
// Cases IFU (cf. commentaire au-dessus de la route) :
case_2TR: Math.round(r.interets_bruts),
case_2BH: r.prelev_sociaux > 0 ? Math.round(r.interets_bruts) : 0,
case_2CK: Math.round(r.prelev_forfaitaire),
// Hors IFU — reportées séparément (2074), jamais fusionnées à 2TR/2TY.
case_3VG: Math.round(r.plus_values),
case_3VH: Math.round(pertesMap[key] ?? 0),
};
});
const moisRows = db.prepare(`
SELECT p.id AS plateforme_id, ipe.investisseur_id, substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.net_recu ELSE 0 END), 0) AS interets_bruts,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.net_recu ELSE 0 END), 0) AS plus_values
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND ipe.mode_detention = 'direct'
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
GROUP BY p.id, ipe.investisseur_id, mois
`).all(...invArgs, workspaceId, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = { interets_bruts: round2(m.interets_bruts), plus_values: round2(m.plus_values) };
}
res.json({ annee, lignes: lignes.map(l => ({ ...l, mois: moisMap[`${l.plateforme_id}_${l.investisseur_id}`] ?? {} })) });
});
export default router;
@@ -0,0 +1,130 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
/**
* routes/transactionsPatrimoine.js — CRUD pour transactions_patrimoine (chantier
* "Visualisation d'un actif", relance Phase 3b, 30/09/26). Cf. commentaire de migration dans
* db/index.js pour le contexte complet (sur le patron de depots_retraits) et
* claude/plan_visualisation_actif.md pour le cadrage.
*
* Cible polymorphe : chaque transaction appartient à EXACTEMENT un compte OU une
* ligne_patrimoine (CHECK en base). Pas de user_id propre sur cette table — la frontière de
* propriété passe par la cible (comptes.user_id ou lignes_patrimoine.user_id), vérifiée
* explicitement ci-dessous à chaque accès (assertTargetOwnership / assertRowOwnership).
*
* Convention de signe sur `montant` (cf. commentaire de migration, db/index.js) : dépôt/achat
* et retrait/vente sont tous deux saisis en positif (le signe métier retrait/vente est appliqué
* au calcul de la valeur courante, pas stocké négatif) — validé ci-dessous côté Zod. reevaluation
* est une variation signée directe, peut être négative.
*/
const Schema = z.object({
compte_id: z.number().int().positive().nullable().optional(),
ligne_patrimoine_id: z.number().int().positive().nullable().optional(),
date_operation: z.string().min(1),
type: z.enum(['depot', 'retrait', 'achat', 'vente', 'reevaluation']),
montant: z.number(),
libelle: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
}).superRefine((body, ctx) => {
const hasCompte = body.compte_id != null;
const hasLigne = body.ligne_patrimoine_id != null;
if (hasCompte === hasLigne) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Exactement un de compte_id ou ligne_patrimoine_id doit être renseigné',
path: ['compte_id'],
});
}
if (body.type !== 'reevaluation' && body.montant < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'montant doit être positif pour ce type de transaction (seul reevaluation accepte une valeur négative)',
path: ['montant'],
});
}
});
// Vérifie que la cible (compte_id ou ligne_patrimoine_id) donnée dans le body appartient bien à
// l'utilisateur courant, avant insert/update.
function assertTargetOwnership(userId, body) {
if (body.compte_id != null) {
const row = db.prepare('SELECT 1 FROM comptes WHERE id = ? AND user_id = ?').get(body.compte_id, userId);
if (!row) throw new HttpError(403, 'Compte not found or not owned by user');
}
if (body.ligne_patrimoine_id != null) {
const row = db.prepare('SELECT 1 FROM lignes_patrimoine WHERE id = ? AND user_id = ?').get(body.ligne_patrimoine_id, userId);
if (!row) throw new HttpError(403, 'Ligne patrimoine not found or not owned by user');
}
}
// Vérifie qu'une transaction EXISTANTE (par id) appartient à l'utilisateur courant, via sa
// cible actuelle — utilisé par PUT/DELETE avant toute modification.
function assertRowOwnership(userId, row) {
if (!row) throw new HttpError(404, 'Not found');
if (row.compte_id != null) {
const c = db.prepare('SELECT 1 FROM comptes WHERE id = ? AND user_id = ?').get(row.compte_id, userId);
if (!c) throw new HttpError(403, 'Forbidden');
} else if (row.ligne_patrimoine_id != null) {
const l = db.prepare('SELECT 1 FROM lignes_patrimoine WHERE id = ? AND user_id = ?').get(row.ligne_patrimoine_id, userId);
if (!l) throw new HttpError(403, 'Forbidden');
}
}
// GET /?compte_id=... ou GET /?ligne_patrimoine_id=... — liste des transactions d'UNE cible.
// Pas de listing global toutes cibles confondues (pas de user_id propre sur la table, cf.
// commentaire d'en-tête) : le frontend appelle toujours cette route depuis la page de gestion
// d'un actif précis, qui connaît déjà son compte_id ou ligne_patrimoine_id.
router.get('/', (req, res, next) => {
try {
const compteId = req.query.compte_id ? Number(req.query.compte_id) : null;
const ligneId = req.query.ligne_patrimoine_id ? Number(req.query.ligne_patrimoine_id) : null;
if (!compteId && !ligneId) throw new HttpError(400, 'compte_id ou ligne_patrimoine_id requis');
if (compteId && ligneId) throw new HttpError(400, 'Un seul de compte_id ou ligne_patrimoine_id à la fois');
assertTargetOwnership(req.user.id, { compte_id: compteId, ligne_patrimoine_id: ligneId });
const rows = compteId
? db.prepare('SELECT * FROM transactions_patrimoine WHERE compte_id = ? ORDER BY date_operation DESC, id DESC').all(compteId)
: db.prepare('SELECT * FROM transactions_patrimoine WHERE ligne_patrimoine_id = ? ORDER BY date_operation DESC, id DESC').all(ligneId);
res.json(rows);
} catch (e) { next(e); }
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
assertTargetOwnership(req.user.id, body);
const r = db.prepare(
'INSERT INTO transactions_patrimoine (compte_id, ligne_patrimoine_id, date_operation, type, montant, libelle, notes) VALUES (?,?,?,?,?,?,?)'
).run(body.compte_id ?? null, body.ligne_patrimoine_id ?? null, body.date_operation, body.type, body.montant, body.libelle ?? null, body.notes ?? null);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const existing = db.prepare('SELECT * FROM transactions_patrimoine WHERE id = ?').get(req.params.id);
assertRowOwnership(req.user.id, existing);
const body = Schema.parse(req.body);
assertTargetOwnership(req.user.id, body);
const changes = db.prepare(
`UPDATE transactions_patrimoine SET compte_id=?, ligne_patrimoine_id=?, date_operation=?, type=?, montant=?, libelle=?, notes=?, updated_at=datetime('now') WHERE id=?`
).run(body.compte_id ?? null, body.ligne_patrimoine_id ?? null, body.date_operation, body.type, body.montant, body.libelle ?? null, body.notes ?? null, req.params.id).changes;
if (changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const existing = db.prepare('SELECT * FROM transactions_patrimoine WHERE id = ?').get(req.params.id);
assertRowOwnership(req.user.id, existing);
db.prepare('DELETE FROM transactions_patrimoine WHERE id = ?').run(req.params.id);
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+78 -4
View File
@@ -17,10 +17,21 @@ const round2 = v => Math.round((v ?? 0) * 100) / 100;
* n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés
* par année via `?annee=`. `gain_net_depuis_debut` est TOUJOURS un cumul
* total (indépendant de `?annee=`) : intérêts nets + cashback/bonus de
* parrainage ou de plateforme + corrections de solde, sur tout
* l'historique du portefeuille — reste valable même après un retrait
* complet des plateformes, contrairement à une approche basée sur le
* solde courant.
* parrainage ou de plateforme + corrections de solde − tous les frais réellement payés en
* dehors du passage brut → net d'un remboursement (à la souscription, porte-monnaie ou
* compte courant du détenteur — cf. /frais-operations ; seul le mode "remboursement" est
* exclu, déjà déduit des intérêts nets ci-dessus). Détail dans
* `gain_net_depuis_debut.frais_hors_remboursement`. Sur tout l'historique du portefeuille —
* reste valable même après un retrait complet des plateformes, contrairement à une approche
* basée sur le solde courant.
*
* Tous les champs ci-dessus (`investissements`, `interets`, `cash`, `gain_net_depuis_debut`)
* portent UNIQUEMENT sur le crowdlending. `investissements_pe` (20/09/26) est un objet
* séparé qui résume le portefeuille Private Equity avec ses propres formules (dernière
* valorisation connue par deal, distributions perçues, TVPI/DPI/RVPI) — mêmes calculs que
* la page Tableau de bord PE de l'application. Volontairement pas fusionné dans les champs
* crowdlending ci-dessus : les deux modèles ne sont pas comparables terme à terme (un deal
* PE n'a ni statut "en retard"/"procédure" ni capital restant dû amortissable).
* tags: [Dashboard]
* security: [{ ApiKeyAuth: [] }]
* parameters:
@@ -121,10 +132,71 @@ router.get('/', (req, res) => {
WHERE ${invCond('investisseur_id')}
`).get(invParam);
// ── Frais hors remboursement (Étape 5, 02/09/26 — cf. project_gain_net_depuis_debut.md) :
// tout frais réel payé par l'investisseur SAUF ceux retenus directement sur un remboursement
// (mode "remboursement") est soustrait ici — ceux-là sont déjà déduits de interets_nets
// ci-dessus au moment du passage brut → net par la plateforme (ex. Fundora), les soustraire une
// 2ᵉ fois créerait une double déduction qui ne correspond à aucune sortie d'argent réelle
// supplémentaire. Les modes "source" (déduit du versement à la souscription — déjà netté dans
// montant_investi, une donnée de capital que ce total n'utilise jamais), "portefeuille" et
// "compte_courant" sont chacun une vraie sortie d'argent jamais reflétée ailleurs dans cette
// somme (qui ne comporte que des flux de revenu), donc tous les trois doivent être déduits — en
// miroir exact du correctif appliqué au KPI "Gain net depuis le début" de l'app interne
// (Dashboard.jsx). Ne pas réutiliser le filtre ('portefeuille','compte_courant') du XIRR net :
// celui-ci exclut "source" pour une raison propre au XIRR (déjà représenté par un flux de
// capital investi plus petit à t0), qui ne s'applique pas ici.
const fraisHorsRembRow = db.prepare(`
SELECT COALESCE(SUM(f.montant), 0) AS total
FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id
WHERE f.mode_reglement != 'remboursement' AND ${invCond('i.investisseur_id')}
`).get(invParam);
const gainNetTotal = round2(
gainNetRow.interets_nets_total + gainNetRow.cashback_total + correctionsRow.total
- fraisHorsRembRow.total
);
// ── Section Private Equity (20/09/26, demande Olivier — améliorer l'API/MCP pour couvrir le
// PE) : mêmes formules que les KPIs de la page Tableau de bord PE de l'app (DashboardPe.jsx,
// useMemo `totals`) — investi = somme montant_investi (tous deals, actifs et soldés) ; nav =
// dernière valorisation connue de chaque deal, ou montant_investi par repli si aucune
// valorisation saisie ; distributions = somme des distributions cumulées perçues ; tvpi/dpi/
// rvpi dérivés. Objet séparé de `investissements` ci-dessus plutôt que fusionné — voir la
// note dans la description OpenAPI au-dessus de cette route.
const invPeCond = req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?';
const invPeRows = db.prepare(`
SELECT ipe.montant_investi, ipe.statut,
(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,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
FROM investissements_pe ipe
WHERE ${invPeCond}
`).all(invParam);
let peInvesti = 0, peNav = 0, peDistributions = 0, peActifs = 0, peSoldes = 0;
for (const r of invPeRows) {
peInvesti += r.montant_investi;
peNav += (r.derniere_valorisation ?? r.montant_investi);
peDistributions += r.distributions_cumulees;
if (r.statut === 'valide' || r.statut === 'en_attente') peActifs++; else peSoldes++;
}
const investissements_pe = {
nb_deals: invPeRows.length,
nb_actifs: peActifs,
nb_soldes: peSoldes,
total_investi: round2(peInvesti),
valorisation_totale: round2(peNav),
distributions_cumulees: round2(peDistributions),
tvpi: peInvesti > 0 ? round2((peNav + peDistributions) / peInvesti) : null,
dpi: peInvesti > 0 ? round2(peDistributions / peInvesti) : null,
rvpi: peInvesti > 0 ? round2(peNav / peInvesti) : null,
};
res.json({
investissements,
interets: { ...interets, annee: annee || null },
@@ -134,7 +206,9 @@ router.get('/', (req, res) => {
interets_nets: round2(gainNetRow.interets_nets_total),
cashback: round2(gainNetRow.cashback_total),
corrections: round2(correctionsRow.total),
frais_hors_remboursement: round2(fraisHorsRembRow.total),
},
investissements_pe,
});
});
+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);
});
+129
View File
@@ -0,0 +1,129 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /frais-operations:
* get:
* summary: Liste des frais liés aux investissements (crowdlending et Private Equity)
* description: >
* Frais facturés par certaines plateformes en dehors des intérêts/prélèvements fiscaux
* habituels : frais d'entrée à la souscription, frais de gestion, frais sur distribution
* (retenus ou non sur un remboursement), ou autre. `mode_reglement` indique comment le
* frais a été réglé — "source" : aucun mouvement de trésorerie réel, montant purement
* indicatif (déjà déduit par la plateforme en amont) ; "portefeuille" : débité du
* porte-monnaie de la plateforme ; "compte_courant" : débité d'un compte courant du
* détenteur (`compte_nom`), sans impact sur le porte-monnaie ; "remboursement" : retenu
* directement sur le remboursement lié (`remboursement_id`), déjà pris en compte dans les
* champs `interets_nets`/`net_recu` de ce remboursement — ne pas le compter une seconde
* fois. Tous les autres modes ("source", "portefeuille", "compte_courant")
* représentent chacun une sortie d'argent réelle (c'est cet ensemble — tout sauf
* "remboursement" — qui est déduit de `gain_net_depuis_debut` sur /dashboard).
*
* Depuis le 20/09/26, couvre aussi bien les frais crowdlending que les frais Private
* Equity (auparavant absents de cette route, bien que déjà présents en base) —
* `source_type` ("crowdlending" ou "private_equity") indique de quel côté vient chaque
* ligne. Une ligne crowdlending porte `investissement_id`/`nom_projet` (et
* `investissement_pe_id`/`nom_deal` valent `null`) ; une ligne PE porte l'inverse.
* `?workspace=<slug>` permet d'isoler un seul des deux univers ; sans ce paramètre, les
* deux sont renvoyés ensemble, triés par date.
* tags: [Frais]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: date_debut
* schema: { type: string, format: date }
* - in: query
* name: date_fin
* schema: { type: string, format: date }
* - in: query
* name: workspace
* schema: { type: string }
* description: >
* Slug du workspace à filtrer (ex. "crowdlending" ou "private-equity"). Absent par
* défaut : renvoie les deux univers confondus — 400 si le slug est inconnu.
* responses:
* 200: { description: Liste des frais }
* 400: { description: Slug de workspace inconnu }
*/
router.get('/', (req, res) => {
const { date_debut, date_fin } = req.query;
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
let workspaceFilter = null;
if (req.query.workspace) {
const ws = db.prepare('SELECT id, type FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
workspaceFilter = ws;
}
const clConds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const clArgs = [invParam];
if (date_debut) { clConds.push('f.date_operation >= ?'); clArgs.push(date_debut); }
if (date_fin) { clConds.push('f.date_operation <= ?'); clArgs.push(date_fin); }
const peConds = [req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?'];
const peArgs = [invParam];
if (date_debut) { peConds.push('f.date_operation >= ?'); peArgs.push(date_debut); }
if (date_fin) { peConds.push('f.date_operation <= ?'); peArgs.push(date_fin); }
// Le côté crowdlending n'a pas besoin d'un filtre workspace_id explicite : la table
// `investissements` n'a pas de colonne workspace_id, il n'existe qu'UN SEUL workspace de type
// 'crowdlending' (singleton imposé en base, cf. db/index.js) et toute ligne crowdlending lui
// appartient donc implicitement. Côté PE, plusieurs workspaces de type 'private_equity' sont
// en revanche possibles (même contrainte) — d'où le filtre explicite ici.
if (workspaceFilter?.type === 'private_equity') {
peConds.push('ipe.workspace_id = ?');
peArgs.push(workspaceFilter.id);
}
const CL_SELECT = `
SELECT 'crowdlending' AS source_type,
f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation, f.remboursement_id,
c.nom AS compte_nom, f.notes,
i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
NULL AS investissement_pe_id, NULL AS nom_deal
FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE ${clConds.join(' AND ')}
`;
// Même ordre de colonnes que CL_SELECT ci-dessus, position par position : UNION ALL associe
// les colonnes par POSITION (pas par nom) — un ordre différent mélangerait silencieusement les
// valeurs (ex. plateforme_nom hérité à la place de nom_deal). Repéré et corrigé lors de la
// vérification par dry-run avant livraison (20/09/26).
const PE_SELECT = `
SELECT 'private_equity' AS source_type,
f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation, f.remboursement_id,
c.nom AS compte_nom, f.notes,
NULL AS investissement_id, NULL AS nom_projet, p.nom AS plateforme_nom,
ipe.id AS investissement_pe_id, ipe.nom_deal
FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE ${peConds.join(' AND ')}
`;
let rows;
if (workspaceFilter?.type === 'crowdlending') {
rows = db.prepare(`${CL_SELECT} ORDER BY f.date_operation DESC, f.id DESC`).all(...clArgs);
} else if (workspaceFilter?.type === 'private_equity') {
rows = db.prepare(`${PE_SELECT} ORDER BY f.date_operation DESC, f.id DESC`).all(...peArgs);
} else {
rows = db.prepare(`
SELECT * FROM (${CL_SELECT} UNION ALL ${PE_SELECT})
ORDER BY date_operation DESC, id DESC
`).all(...clArgs, ...peArgs);
}
res.json(rows);
});
export default router;
+4
View File
@@ -1,8 +1,10 @@
import { Router } from 'express';
import investisseurRouter from './investisseur.js';
import investissementsRouter from './investissements.js';
import investissementsPeRouter from './investissementsPe.js';
import remboursementsRouter from './remboursements.js';
import depotsRetraitsRouter from './depotsRetraits.js';
import fraisOperationsRouter from './fraisOperations.js';
import dashboardRouter from './dashboard.js';
import objectifsRouter from './objectifs.js';
import taxreportRouter from './taxreport.js';
@@ -11,8 +13,10 @@ const router = Router();
router.use('/investisseur', investisseurRouter);
router.use('/investissements', investissementsRouter);
router.use('/investissements-pe', investissementsPeRouter);
router.use('/remboursements', remboursementsRouter);
router.use('/depots-retraits', depotsRetraitsRouter);
router.use('/frais-operations', fraisOperationsRouter);
router.use('/dashboard', dashboardRouter);
router.use('/objectifs', objectifsRouter);
router.use('/taxreport', taxreportRouter);
+30 -6
View File
@@ -50,6 +50,15 @@ router.get('/', (req, res) => {
* /investissements/{id}:
* get:
* summary: Détail d'un investissement, avec ses remboursements réels
* description: >
* `remboursements[].frais_ttc` est le montant de frais saisi sur ce remboursement ; il ne
* réduit `interets_nets`/`net_recu` que si `frais_mode_reglement` vaut "remboursement".
* `frais_operations` liste tous les frais rattachés à cet investissement (souscription,
* gestion, distribution, autre), quel que soit leur mode de règlement — à la source
* (aucun mouvement réel, montant indicatif), porte-monnaie, compte courant, ou retenu sur
* un remboursement (auto-synchronisé, `remboursement_id` renseigné). Seuls les frais en
* mode "portefeuille" ou "compte_courant" représentent une sortie d'argent réelle et
* distincte des remboursements.
* tags: [Investissements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
@@ -77,14 +86,29 @@ router.get('/:id', (req, res, next) => {
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const remboursements = db.prepare(`
SELECT id, date_remb, capital, interets_bruts, prelev_sociaux,
prelev_forfaitaire, cashback, interets_nets, net_recu, statut
FROM remboursements
WHERE investissement_id = ?
ORDER BY date_remb
SELECT r.id, r.date_remb, r.capital, r.interets_bruts, r.frais_ttc,
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement,
r.prelev_sociaux, r.prelev_forfaitaire, r.cashback, r.interets_nets, r.net_recu, r.statut
FROM remboursements r
WHERE r.investissement_id = ?
ORDER BY r.date_remb
`).all(inv.id);
res.json({ ...inv, remboursements });
// Frais liés à cet investissement (souscription, gestion, distribution, autre — tous modes
// de règlement confondus). Les lignes de catégorie 'distribution' liées à un remboursement
// (remboursement_id non nul) sont synchronisées avec le champ frais_ttc de ce remboursement
// ci-dessus — ne pas les additionner aux deux si frais_mode_reglement = 'remboursement'
// (le montant est alors déjà déduit de interets_nets/net_recu, cf. /remboursements).
const fraisOperations = db.prepare(`
SELECT f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation,
f.remboursement_id, c.nom AS compte_nom, f.notes
FROM frais_operations f
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE f.investissement_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(inv.id);
res.json({ ...inv, remboursements, frais_operations: fraisOperations });
} catch (e) { next(e); }
});
+165
View File
@@ -0,0 +1,165 @@
import { Router } from 'express';
import db from '../../db/index.js';
import { HttpError } from '../../middleware/errorHandler.js';
const router = Router();
// Mêmes colonnes enrichies que GET /api/investissements-pe (route interne,
// investissementsPe.js) : dernière valorisation connue, distributions cumulées perçues
// (remboursements liés), et le TVPI qui en découle — même formule que la fiche détail interne
// et que DashboardPe.jsx (nav = dernière valorisation, ou montant_investi si aucune
// valorisation saisie). Colonnes et FROM/JOIN séparés (plutôt qu'un seul bloc SELECT...FROM) pour
// que la route détail puisse insérer `ipe.notes` dans la liste de colonnes SANS le faire atterrir
// après le FROM/JOIN, ce qui produirait du SQL invalide (repéré et corrigé lors de la
// vérification par dry-run avant livraison, 20/09/26).
const ENRICHED_COLUMNS = `
ipe.id, ipe.nom_deal, ipe.strategie, p.nom AS plateforme_nom,
ipe.date_souscription, ipe.duree_mois, ipe.montant_investi, ipe.capital_investissable,
ipe.objectif, ipe.statut, ipe.reference,
(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,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
AS obligation_cumulee
`;
const ENRICHED_FROM = `
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
`;
function withTvpi(row) {
return {
...row,
tvpi: row.derniere_valorisation != null
? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100
: null,
};
}
/**
* @openapi
* /investissements-pe:
* get:
* summary: Liste des investissements Private Equity de l'investisseur
* description: >
* Distinct de /investissements (crowdlending) : un deal PE n'a ni échéancier connu à
* l'avance ni taux d'intérêt fixe — un montant unique investi (`montant_investi`), valorisé
* périodiquement (`derniere_valorisation`, `derniere_valorisation_date`), avec des
* distributions perçues au fil du temps (`distributions_cumulees`, un sous-ensemble marqué
* `obligation_cumulee` quand il s'agit d'intérêts sur capital non encore déployé).
* `tvpi` (Total Value to Paid-In = (dernière valorisation + distributions cumulées) /
* montant investi) est `null` tant qu'aucune valorisation n'a été saisie pour ce deal —
* mêmes formules que la page Investissements PE / le tableau de bord PE de l'application.
* tags: [Investissements PE]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: statut
* schema: { type: string, enum: [en_attente, valide, cloture, perte_definitive] }
* - in: query
* name: workspace
* schema: { type: string }
* description: >
* Slug du workspace à filtrer (utile si plusieurs workspaces Private Equity existent).
* Absent par défaut : tous les deals PE de l'investisseur, tous workspaces confondus.
* responses:
* 200: { description: Liste des investissements PE }
* 400: { description: Slug de workspace inconnu }
*/
router.get('/', (req, res) => {
const { statut } = req.query;
const conds = [req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (statut) { conds.push('ipe.statut = ?'); args.push(statut); }
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('ipe.workspace_id = ?');
args.push(ws.id);
}
const rows = db.prepare(`
SELECT ${ENRICHED_COLUMNS}
${ENRICHED_FROM}
WHERE ${conds.join(' AND ')}
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...args);
res.json(rows.map(withTvpi));
});
/**
* @openapi
* /investissements-pe/{id}:
* get:
* summary: Détail d'un investissement PE, avec son historique de valorisations
* description: >
* `valorisations` est l'historique complet des valorisations saisies pour ce deal (la plus
* récente de cette liste est celle utilisée pour `derniere_valorisation`/`tvpi`).
* `remboursements` liste les distributions perçues (retour de capital, plus-value —
* saisies comme des remboursements de type "intérêts plateforme", cf.
* /remboursements) ; `frais_operations` liste les frais rattachés à ce deal, quel que
* soit leur mode de règlement.
* tags: [Investissements PE]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Détail de l'investissement PE }
* 404: { description: Investissement PE introuvable }
*/
router.get('/:id', (req, res, next) => {
try {
const invCond = req.investisseurScopeAll
? 'ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'ipe.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const row = db.prepare(`
SELECT ${ENRICHED_COLUMNS}, ipe.notes
${ENRICHED_FROM}
WHERE ipe.id = ? AND ${invCond}
`).get(req.params.id, invParam);
if (!row) throw new HttpError(404, 'Investissement PE introuvable');
const valorisations = db.prepare(`
SELECT id, date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes
FROM investissements_pe_valorisations
WHERE investissement_pe_id = ?
ORDER BY date_valorisation DESC, id DESC
`).all(row.id);
const remboursements = db.prepare(`
SELECT id, date_remb, capital, interets_bruts, cashback, prelev_sociaux, prelev_forfaitaire,
interets_nets, net_recu, statut, distribution_nature
FROM remboursements
WHERE investissement_pe_id = ?
ORDER BY date_remb DESC
`).all(row.id);
const fraisOperations = db.prepare(`
SELECT f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation,
f.remboursement_id, c.nom AS compte_nom, f.notes
FROM frais_operations f
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(row.id);
res.json({ ...withTvpi(row), valorisations, remboursements, frais_operations: fraisOperations });
} catch (e) { next(e); }
});
export default router;
+26 -1
View File
@@ -8,6 +8,12 @@ const router = Router();
* /remboursements:
* get:
* summary: Liste des remboursements réels perçus
* description: >
* `frais_ttc` est toujours le montant de frais saisi sur ce remboursement, mais il ne
* réduit `interets_nets`/`net_recu` que lorsque `frais_mode_reglement` vaut
* "remboursement" (retenu sur ce versement). Avec "portefeuille" ou "compte_courant",
* le frais est réglé à part (cf. /frais-operations) et n'a aucun impact sur ce
* remboursement — ne pas le soustraire une seconde fois.
* tags: [Remboursements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
@@ -17,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;
@@ -31,9 +45,20 @@ 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.prelev_sociaux, r.prelev_forfaitaire,
r.capital, r.interets_bruts, r.frais_ttc,
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement,
r.prelev_sociaux, r.prelev_forfaitaire,
r.cashback, r.interets_nets, r.net_recu, r.statut
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
+39 -11
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import db from '../../db/index.js';
import { buildCerfa2561, build2778, build2047 } from '../taxreport.js';
import { buildCerfa2561, build2778, build2047, build2042CPro } from '../taxreport.js';
const router = Router();
@@ -21,12 +21,22 @@ const sumBy = (arr, key) => arr.reduce((s, x) => s + (x[key] ?? 0), 0);
* `synthese_2042` reprend les cases 2TT / 2TR / 2BH / 2CK / 2TY telles qu'affichées
* en haut de la page Fiscalité. `cerfa2561` détaille le récapitulatif IFU des
* plateformes françaises soumises au PFU. `cerfa2047` (présent seulement si
* `has_etranger`) détaille, par plateforme étrangère, l'éligibilité au crédit
* d'impôt conventionnel (cases 231 à 238 vs 250) et le report vers 8VL/8PL.
* `has_etranger`) détaille, par plateforme étrangère "Prestataire de Services de
* Financement Participatif" (PSFP), l'éligibilité au crédit d'impôt conventionnel
* (cases 231 à 238 vs 250) et le report vers 8VL/8PL.
* `cerfa2778_mensuel` (présent seulement si `has_etranger`) donne le détail mensuel
* des intérêts bruts par plateforme étrangère, base de calcul du prélèvement
* des intérêts bruts par plateforme étrangère PSFP, base de calcul du prélèvement
* forfaitaire obligatoire (PFO) — le PFO n'est légalement dû que si `pfo_assujetti`
* est vrai (seuil de revenu fiscal de référence, cf. Paramétrage → Ma fiscalité).
* Les plateformes "Prestataire de Services sur Actifs Numériques" (PSAN) ne sont
* jamais concernées par les CERFA 2047/2778-SD, même si domiciliées hors de France.
*
* `cerfa2042cpro` (présent seulement si `has_psan`) donne les recettes brutes crypto
* (staking/rewards) des plateformes PSAN, quelle que soit leur domiciliation — imposables
* en BNC, régime micro par défaut (case 5HQ, abattement forfaitaire de 34 % appliqué
* automatiquement par l'administration ; au-delà de 77 700 €, le régime réel 5KO/5KP
* s'applique, non calculé ici). Ce n'est pas un revenu de source étrangère : aucun crédit
* d'impôt, aucun report vers `synthese_2042`.
*
* Limite connue : `case_2BH` / `case_2CK` de `synthese_2042` supposent qu'aucune
* plateforme étrangère n'a été exclue manuellement du calcul PFO dans l'application
@@ -67,14 +77,25 @@ router.get('/', (req, res) => {
).get(req.userId);
const pfoAssujetti = prefRow ? prefRow.value === 'true' : false;
// Au moins une plateforme domiciliée hors de France, tous détenteurs du foyer confondus
// et indépendamment de l'année — la table `plateformes` est scopée par `user_id` (pas
// `investisseur_id`, qui n'est que le détenteur assigné) dans toute l'application (cf.
// GET /api/plateformes), et hasEtranger côté frontend (TaxReport.jsx) est calculé sur
// cette même liste non filtrée par investisseur actif — un comportement volontairement
// répliqué ici, même avec une clé API mono-investisseur.
// Au moins une plateforme domiciliée hors de France ET de catégorie "Prestataire de
// Services de Financement Participatif" (PSFP), tous détenteurs du foyer confondus et
// indépendamment de l'année — les plateformes "Prestataire de Services sur Actifs
// Numériques" (PSAN) ne sont pas concernées par les CERFA 2047/2778-SD (demande Olivier
// 30/08/26), même si elles sont domiciliées hors de France. La table `plateformes` est
// scopée par `user_id` (pas `investisseur_id`, qui n'est que le détenteur assigné) dans
// toute l'application (cf. GET /api/plateformes), et hasEtranger côté frontend
// (TaxReport.jsx) est calculé sur cette même liste non filtrée par investisseur actif —
// un comportement volontairement répliqué ici, même avec une clé API mono-investisseur.
const hasEtranger = !!db.prepare(
`SELECT 1 FROM plateformes WHERE user_id = ? AND domiciliation != 'FR' LIMIT 1`
`SELECT 1 FROM plateformes WHERE user_id = ? AND domiciliation != 'FR' AND categorie_plateforme = 'psfp' LIMIT 1`
).get(req.userId);
// Au moins une plateforme "Prestataire de Services sur Actifs Numériques" (PSAN), quelle que
// soit sa domiciliation — les recettes crypto (staking/rewards) sont des BNC de par leur nature,
// pas de par la localisation de la plateforme (contrairement à hasEtranger ci-dessus). Même
// principe de réplication du calcul frontend (TaxReport.jsx) qu'hasEtranger.
const hasPsan = !!db.prepare(
`SELECT 1 FROM plateformes WHERE user_id = ? AND categorie_plateforme = 'psan' LIMIT 1`
).get(req.userId);
const cerfa2561 = buildCerfa2561(annee, invCond, invCondBonus, invParam);
@@ -86,6 +107,11 @@ router.get('/', (req, res) => {
cerfa2778_mensuel = build2778(annee, invCond, invCondBonus, invParam);
}
let cerfa2042cpro = null;
if (hasPsan) {
cerfa2042cpro = build2042CPro(annee, invCond, invCondBonus, invParam);
}
// ── Synthèse 2042 — mêmes cases et mêmes formules que le pavé "Cases fiscales 2042 —
// synthèse" en haut de la page Fiscalité (TaxReport.jsx, fonction load()) :
// - 2TT : cumul direct des lignes 2561 françaises. 2TY (pertes en capital,
@@ -115,11 +141,13 @@ router.get('/', (req, res) => {
res.json({
annee,
has_etranger: hasEtranger,
has_psan: hasPsan,
pfo_assujetti: pfoAssujetti,
synthese_2042,
cerfa2561,
cerfa2047,
cerfa2778_mensuel,
cerfa2042cpro,
});
});
@@ -0,0 +1,47 @@
import { Router } from 'express';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
/**
* routes/valorisationsPatrimoine.js — lecture seule pour valorisations_patrimoine (chantier
* "Historique de valeur", 30/09/26, demande Olivier). Cf. commentaire de migration dans
* db/index.js pour le contexte complet et utils/valorisationsPatrimoine.js pour l'écriture
* (automatique, sur solde_eur/valeur_eur — pas de route POST/PUT/DELETE ici : ces points sont
* dérivés des saisies sur comptes.js/lignesPatrimoine.js, jamais créés directement par
* l'utilisateur).
*
* Même patron de vérification de propriété que transactionsPatrimoine.js : pas de user_id
* propre sur cette table, la frontière passe par la cible (comptes.user_id ou
* lignes_patrimoine.user_id).
*/
function assertTargetOwnership(userId, { compteId, ligneId }) {
if (compteId != null) {
const row = db.prepare('SELECT 1 FROM comptes WHERE id = ? AND user_id = ?').get(compteId, userId);
if (!row) throw new HttpError(403, 'Compte not found or not owned by user');
}
if (ligneId != null) {
const row = db.prepare('SELECT 1 FROM lignes_patrimoine WHERE id = ? AND user_id = ?').get(ligneId, userId);
if (!row) throw new HttpError(403, 'Ligne patrimoine not found or not owned by user');
}
}
// GET /?compte_id=... ou GET /?ligne_patrimoine_id=... — historique de valeur d'UNE cible,
// ordre chronologique croissant (pratique pour tracer directement un graphique de progression).
router.get('/', (req, res, next) => {
try {
const compteId = req.query.compte_id ? Number(req.query.compte_id) : null;
const ligneId = req.query.ligne_patrimoine_id ? Number(req.query.ligne_patrimoine_id) : null;
if (!compteId && !ligneId) throw new HttpError(400, 'compte_id ou ligne_patrimoine_id requis');
if (compteId && ligneId) throw new HttpError(400, 'Un seul de compte_id ou ligne_patrimoine_id à la fois');
assertTargetOwnership(req.user.id, { compteId, ligneId });
const rows = compteId
? db.prepare('SELECT * FROM valorisations_patrimoine WHERE compte_id = ? ORDER BY date_valorisation ASC, id ASC').all(compteId)
: db.prepare('SELECT * FROM valorisations_patrimoine WHERE ligne_patrimoine_id = ? ORDER BY date_valorisation ASC, id ASC').all(ligneId);
res.json(rows);
} catch (e) { next(e); }
});
export default router;
+81
View File
@@ -0,0 +1,81 @@
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
* état d'activation personnelle (active_by_user) — un workspace non accordé
* n'apparaît jamais ici, même désactivé. Voir aussi adminWorkspaces.js
* (catalogue global) et admin.js (octroi par utilisateur, PATCH
* /admin/users/:id/workspaces). requireAuth appliqué dans server.js. */
const router = Router();
router.get('/', (req, res) => {
// w.type (19/09/26) : c'est ce champ, PAS w.slug, que le frontend doit utiliser pour choisir
// entre les pages "Crowdlending" et "Private Equity" (App.jsx, Layout.jsx) — cf. db/index.js
// pour le contexte. slug reste exposé (identifiant technique stable, ex. exports/imports ZIP)
// mais ne porte plus la sémantique de routage.
const rows = db.prepare(`
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, w.type, 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
ORDER BY w.ordre ASC, w.id ASC
`).all(req.user.id);
res.json(rows);
});
const ActiveSchema = z.object({ active: z.boolean() });
router.patch('/:id/active', (req, res, next) => {
try {
const { active } = ActiveSchema.parse(req.body);
const workspaceId = Number(req.params.id);
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 active_by_user = ? WHERE user_id = ? AND workspace_id = ?'
).run(active ? 1 : 0, req.user.id, workspaceId);
res.json({ id: workspaceId, active });
} 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;
+62 -4
View File
@@ -15,10 +15,13 @@ 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 taxreportPeRouter from './routes/taxreportPe.js';
import platefomeTaxRouter from './routes/plateforme-tax.js';
import importsRouter from './routes/imports.js';
import pfuRouter from './routes/pfu.js';
@@ -26,6 +29,7 @@ import notationRouter from './routes/notation.js';
import garantiesRouter from './routes/garanties.js';
import objectifsRouter from './routes/objectifs.js';
import reinvestissementsRouter from './routes/reinvestissements.js';
import fraisOperationsRouter from './routes/fraisOperations.js';
import correctionsRouter from './routes/corrections.js';
import comptesRouter from './routes/comptes.js';
import preferencesRouter from './routes/preferences.js';
@@ -42,9 +46,20 @@ import invitationsRouter from './routes/invitations.js';
import auditLogsRouter from './routes/auditLogs.js';
import smtpRouter from './routes/smtp.js';
import generalRouter from './routes/general.js';
import adminAiRouter from './routes/adminAi.js';
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
import referentielRouter from './routes/referentiel.js';
import referentielPublicRouter from './routes/referentielPublic.js';
import institutionsReferentielRouter from './routes/institutionsReferentiel.js';
import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
import enveloppesReferentielRouter from './routes/enveloppesReferentiel.js';
import enveloppesReferentielPublicRouter from './routes/enveloppesReferentielPublic.js';
import supportsReferentielRouter from './routes/supportsReferentiel.js';
import empruntsRouter from './routes/emprunts.js';
import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js';
import regroupementsPatrimoniauxRouter from './routes/regroupementsPatrimoniaux.js';
import regroupementsPatrimoniauxPublicRouter from './routes/regroupementsPatrimoniauxPublic.js';
import refCategoriesRouter from './routes/ref-categories.js';
import refSecteursRouter from './routes/ref-secteurs.js';
import categoriesInvRouter from './routes/categories-inv.js';
@@ -54,6 +69,16 @@ import notificationsRouter from './routes/notifications.js';
import ticketsRouter from './routes/tickets.js';
import apiKeysRouter from './routes/apiKeys.js';
import v1Router from './routes/v1/index.js';
import documentsRouter from './routes/documents.js';
import aiRouter from './routes/ai.js';
import workspacesRouter from './routes/workspaces.js';
import adminWorkspacesRouter from './routes/adminWorkspaces.js';
import dashboardPatrimoineRouter from './routes/dashboardPatrimoine.js';
// Chantier "Visualisation d'un actif" (relance Phase 3b, 30/09/26) — cf. commentaire de
// migration dans db/index.js et claude/plan_visualisation_actif.md.
import lignesPatrimoineRouter from './routes/lignesPatrimoine.js';
import transactionsPatrimoineRouter from './routes/transactionsPatrimoine.js';
import valorisationsPatrimoineRouter from './routes/valorisationsPatrimoine.js';
import { requireApiKey } from './middleware/apiKey.js';
import { swaggerSpec, swaggerUi } from './swagger.js';
import db from './db/index.js';
@@ -92,18 +117,23 @@ app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOStri
// Informations publiques de la plateforme (utilisées sur la page de login)
app.get('/api/app-info', (_, res) => {
try {
const cfg = getSmtpConfig();
const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get();
const cfg = getSmtpConfig();
const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get();
// Thème de couleur (Admin > Général) : appliqué à TOUS les utilisateurs,
// y compris sur la page de connexion (avant authentification) — d'où son
// exposition ici, comme allowRegistration/mcpUrl déjà publics.
const theme = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get();
res.json({
appName: cfg.appName || 'Crowdlending Tracker',
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
allowRegistration: row ? row.allow_registration !== 0 : true,
minPasswordLength: row ? (row.min_password_length || 8) : 8,
mcpUrl: row ? (row.mcp_url || '') : '',
colorTheme: theme?.color_theme || 'classique',
});
} catch {
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '' });
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '', colorTheme: 'classique' });
}
});
@@ -124,10 +154,13 @@ 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/taxreport-pe', requireAuth, taxreportPeRouter);
app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter);
app.use('/api/imports', requireAuth, importsRouter);
app.use('/api/pfu', requireAuth, pfuRouter);
@@ -135,20 +168,37 @@ app.use('/api/notation', requireAuth, notationRouter);
app.use('/api/garanties', requireAuth, garantiesRouter);
app.use('/api/objectifs', requireAuth, objectifsRouter);
app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter);
app.use('/api/frais-operations', requireAuth, fraisOperationsRouter);
app.use('/api/corrections', requireAuth, correctionsRouter);
app.use('/api/comptes', requireAuth, comptesRouter);
app.use('/api/emprunts', requireAuth, empruntsRouter);
app.use('/api/preferences', requireAuth, preferencesRouter);
app.use('/api/icons', requireAuth, iconsRouter);
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
app.use('/api/admin/audit-logs', requireAuth, requireAdmin, auditLogsRouter);
app.use('/api/admin/smtp', requireAuth, requireAdmin, smtpRouter);
app.use('/api/admin/general', requireAuth, requireAdmin, generalRouter);
app.use('/api/admin/ai', requireAuth, requireAdmin, adminAiRouter);
// Invitations : routes admin protégées + routes publiques (register/validate)
app.use('/api/admin/invitations', requireAuth, requireAdmin, invitationsRouter);
app.use('/api/invitations', invitationsRouter);
app.use('/api/taux-credit-impot', requireAuth, tauxCreditImpotRouter);
app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter);
app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
// Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) — Phase 1 :
// catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js.
app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter);
app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter);
app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter);
// Chantier "Référentiel Enveloppes & Supports d'épargne" (28/09/26, demande Olivier) —
// catalogue admin transverse aux workspaces, catalogue seul pour l'instant (pas de
// rattachement à l'existant), cf. enveloppesReferentiel.js.
app.use('/api/enveloppes-referentiel', requireAuth, requireAdmin, enveloppesReferentielRouter);
app.use('/api/enveloppes-referentiel-public', requireAuth, enveloppesReferentielPublicRouter);
app.use('/api/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter);
app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter);
app.use('/api/regroupements-patrimoniaux', requireAuth, requireAdmin, regroupementsPatrimoniauxRouter);
app.use('/api/regroupements-patrimoniaux-public', requireAuth, regroupementsPatrimoniauxPublicRouter);
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
app.use('/api/categories-inv', requireAuth, categoriesInvRouter);
@@ -157,6 +207,14 @@ app.use('/api', requireAuth, associationsInvRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/tickets', requireAuth, ticketsRouter);
app.use('/api/api-keys', requireAuth, apiKeysRouter);
app.use('/api/documents', requireAuth, documentsRouter);
app.use('/api/ai', requireAuth, aiRouter);
app.use('/api/workspaces', requireAuth, workspacesRouter);
app.use('/api/admin/workspaces', requireAuth, requireAdmin, adminWorkspacesRouter);
app.use('/api/dashboard-patrimoine', requireAuth, dashboardPatrimoineRouter);
app.use('/api/lignes-patrimoine', requireAuth, lignesPatrimoineRouter);
app.use('/api/transactions-patrimoine', requireAuth, transactionsPatrimoineRouter);
app.use('/api/valorisations-patrimoine', requireAuth, valorisationsPatrimoineRouter);
app.use(errorHandler);
+3 -1
View File
@@ -21,7 +21,9 @@ const swaggerSpec = swaggerJsdoc({
title: 'Crowdlending Tracker API',
version: 'v1',
description:
"API publique en lecture seule du portefeuille de crowdlending. " +
"API publique en lecture seule du portefeuille — crowdlending et Private Equity " +
"(cf. /investissements pour le crowdlending, /investissements-pe pour le PE ; " +
"/remboursements, /depots-retraits et /frais-operations couvrent les deux). " +
"Authentification par clé API (header `X-API-Key`), générée depuis Mon compte → Clés API. " +
"Chaque clé est scopée à un seul investisseur.",
},
@@ -0,0 +1,41 @@
/**
* valorisationsPatrimoine.js — Helper d'enregistrement de l'historique de valeur
* (chantier "Historique de valeur", 30/09/26, demande Olivier).
*
* Chaque appel à recordValorisation() associe la valeur saisie manuellement sur un compte
* ou une ligne de patrimoine à la date du jour, dans la table valorisations_patrimoine
* (cf. migration dans db/index.js). Un seul point par jour et par actif : un second appel
* le même jour MET À JOUR le point existant plutôt que d'en créer un doublon (l'utilisateur
* qui corrige sa saisie plusieurs fois dans la journée ne doit pas polluer l'historique).
*
* N'écrit rien si valeur est null/undefined (un actif "non renseigné" ne produit pas de point).
*
* Usage :
* import { recordValorisation } from '../utils/valorisationsPatrimoine.js';
* recordValorisation({ compteId: id, valeur: body.solde_eur });
* recordValorisation({ lignePatrimoineId: id, valeur: body.valeur_eur });
*
* À appeler à l'intérieur de la même transaction db.transaction(...) que l'INSERT/UPDATE
* du compte ou de la ligne, pour rester atomique avec la modification de l'actif.
*/
import db from '../db/index.js';
export function recordValorisation({ compteId = null, lignePatrimoineId = null, valeur }) {
if (valeur === null || valeur === undefined) return;
const updated = db.prepare(`
UPDATE valorisations_patrimoine
SET valeur = ?, created_at = datetime('now')
WHERE date_valorisation = date('now')
AND compte_id IS ?
AND ligne_patrimoine_id IS ?
`).run(valeur, compteId, lignePatrimoineId).changes;
if (!updated) {
db.prepare(`
INSERT INTO valorisations_patrimoine (compte_id, ligne_patrimoine_id, date_valorisation, valeur)
VALUES (?, ?, date('now'), ?)
`).run(compteId, lignePatrimoineId, valeur);
}
}
+49 -3
View File
@@ -28,6 +28,15 @@ function dosDateTime(d = new Date()) {
return { time, date };
}
// ── Nettoyage d'un segment de chemin ZIP ────────────────────────────────────
// Retire les caractères interdits dans un nom de fichier/dossier cross-platform
// (\ / : * ? " < > | + caractères de contrôle) tout en conservant accents et
// espaces. Partagé par tous les exports ZIP de l'app (documents, investissements...)
// pour que la même règle de nommage s'applique partout.
export function sanitizeZipPart(name) {
return String(name).replace(/[\\/:*?"<>|\u0000-\u001F]/g, ' ').replace(/\s+/g, ' ').trim() || 'Sans nom';
}
/**
* createZip(entries) → Buffer
* entries: [{ name: string, data: Buffer|string }]
@@ -54,7 +63,14 @@ export function createZip(entries) {
const lh = Buffer.alloc(30 + nameBuf.length);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4);
lh.writeUInt16LE(0, 6);
// Bit 11 (0x0800) = "Language encoding flag (EFS)" — indique que le nom
// de fichier est encodé en UTF-8. Sans ce bit, les extracteurs qui ne
// devinent pas l'UTF-8 par défaut (Windows Explorer notamment) réinterprètent
// les octets UTF-8 des noms accentués avec la page de code système, d'où le
// mojibake constaté par Olivier ("Billet_électronique" -> "Billet_ Ⓡlectronique").
// Les noms sont toujours écrits en UTF-8 ci-dessus (Buffer.from(name, 'utf8')),
// ce bit est donc toujours correct à poser.
lh.writeUInt16LE(0x0800, 6);
lh.writeUInt16LE(method, 8);
lh.writeUInt16LE(modTime, 10);
lh.writeUInt16LE(modDate, 12);
@@ -77,7 +93,7 @@ export function createZip(entries) {
cd.writeUInt32LE(0x02014b50, 0);
cd.writeUInt16LE(20, 4);
cd.writeUInt16LE(20, 6);
cd.writeUInt16LE(0, 8);
cd.writeUInt16LE(0x0800, 8); // même flag UTF-8 (EFS) que l'en-tête local, cf. commentaire ci-dessus
cd.writeUInt16LE(h.method, 10);
cd.writeUInt16LE(h.modTime, 12);
cd.writeUInt16LE(h.modDate, 14);
@@ -113,6 +129,25 @@ export function createZip(entries) {
return Buffer.concat(chunks);
}
// ── Protection contre les "zip bombs" ───────────────────────────────────
// readZip() est utilisé pour relire des zips produits en interne par l'appli
// (exports/sauvegardes admin — contenu maîtrisé) MAIS AUSSI pour des zips
// envoyés directement par un utilisateur (import de dossier d'investissement,
// import PFU/plateformes/référentiel) — donc potentiellement malveillants.
// Un zip conçu avec des données très répétitives peut annoncer quelques Ko
// compressés pour plusieurs Go décompressés (ratio extrême), ce qui saturerait
// la mémoire du process si rien ne le limite. Deux garde-fous complémentaires :
// 1. Rejet précoce si la taille décompressée ANNONCÉE dans l'en-tête central
// dépasse déjà la limite (évite même de lancer la décompression).
// 2. `maxOutputLength` passé à zlib : filet de sécurité si l'en-tête ment sur
// la taille annoncée — zlib interrompt la décompression dès que la sortie
// réelle dépasse la limite, sans jamais allouer plus que nécessaire.
// Limites généreuses par rapport à l'usage réel de l'appli (sauvegarde complète
// = base + logos/icons/documents, quelques dizaines de Mo en pratique) tout en
// bornant strictement le pire cas.
export const MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES = 500 * 1024 * 1024; // 500 Mo par entrée
export const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024; // 1 Go cumulé par zip
/**
* readZip(buffer) → [{ name: string, data: Buffer }]
*/
@@ -129,6 +164,7 @@ export function readZip(buffer) {
const entries = [];
let pos = cdOffset;
let totalUncompressed = 0;
for (let i = 0; i < entryCount; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte');
@@ -141,13 +177,23 @@ export function readZip(buffer) {
const localOffset = buffer.readUInt32LE(pos + 42);
const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen);
if (uncompSize > MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES) {
throw new Error(`ZIP invalide : l'entrée "${name}" dépasse la taille décompressée maximale autorisée (${Math.round(MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
}
totalUncompressed += uncompSize;
if (totalUncompressed > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES) {
throw new Error(`ZIP invalide : la taille décompressée totale dépasse la limite autorisée (${Math.round(MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
}
// Read local file header to get actual extra field length
const localNameLen = buffer.readUInt16LE(localOffset + 26);
const localExtraLen = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
const compData = buffer.subarray(dataStart, dataStart + compSize);
const data = method === 0 ? compData : zlib.inflateRawSync(compData);
// maxOutputLength : filet de sécurité si l'en-tête ment sur uncompSize —
// voir commentaire au-dessus des constantes MAX_ZIP_*.
const data = method === 0 ? compData : zlib.inflateRawSync(compData, { maxOutputLength: MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES });
entries.push({ name, data: Buffer.from(data) });
pos += 46 + nameLen + extraLen + commentLen;
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

+98
View File
@@ -0,0 +1,98 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M4894 8872 c-31 -25 -40 -47 -54 -142 -20 -135 -18 -131 -96 -161
-38 -14 -100 -40 -139 -58 -38 -17 -75 -29 -82 -27 -7 3 -49 33 -93 67 -56 42
-92 63 -118 66 -35 5 -44 0 -98 -45 -32 -27 -101 -92 -151 -145 -82 -85 -93
-100 -93 -132 0 -43 4 -52 75 -151 30 -42 55 -85 55 -95 0 -11 -11 -41 -24
-67 -13 -26 -34 -78 -46 -117 -13 -38 -28 -76 -35 -83 -10 -11 -63 -23 -178
-37 -33 -4 -58 -15 -80 -35 l-32 -28 0 -207 0 -207 35 -32 c31 -28 47 -34 125
-45 128 -18 129 -19 160 -103 15 -40 41 -101 58 -135 l30 -62 -36 -53 c-20
-29 -53 -75 -72 -102 -53 -73 -49 -102 23 -179 264 -282 262 -281 418 -151 71
60 87 62 167 21 36 -20 96 -45 132 -57 36 -12 69 -29 74 -38 4 -10 14 -55 21
-102 26 -183 21 -180 277 -180 253 0 264 7 284 170 6 52 15 103 20 112 5 10
28 22 51 28 23 7 83 32 135 57 109 53 102 54 213 -31 78 -61 105 -72 147 -61
39 9 299 276 308 314 8 37 -1 54 -81 159 -35 45 -64 87 -64 92 0 5 14 36 30
69 17 33 42 90 55 127 13 37 29 72 34 77 6 6 53 16 105 23 53 7 108 20 124 28
56 29 57 32 57 257 0 206 0 208 -24 233 -27 29 -65 41 -161 51 -100 11 -105
15 -130 92 -13 37 -38 99 -57 136 -39 79 -43 65 53 189 61 79 71 106 55 146
-7 16 -72 88 -145 161 -116 116 -138 133 -172 138 -33 4 -46 0 -89 -31 -27
-19 -73 -52 -102 -72 l-52 -36 -93 45 c-51 25 -112 51 -134 56 -23 6 -47 18
-54 26 -7 8 -18 54 -25 102 -19 124 -24 141 -52 163 -24 19 -41 20 -232 20
-180 0 -209 -3 -227 -18z m376 -586 c88 -16 217 -64 280 -104 120 -76 237
-204 305 -332 62 -119 85 -221 85 -378 0 -163 -15 -231 -80 -367 -192 -399
-653 -572 -1059 -396 -224 96 -392 276 -460 491 -63 199 -65 342 -7 532 48
157 170 317 317 419 71 49 85 56 179 95 124 51 293 66 440 40z"/>
<path d="M5430 8041 l-65 -6 0 -430 0 -430 88 -3 c57 -2 94 1 103 9 12 9 14
84 14 434 0 459 3 438 -53 433 -12 -1 -51 -4 -87 -7z"/>
<path d="M5022 7828 c-19 -19 -17 -631 2 -647 9 -8 46 -11 103 -9 l88 3 7 60
c3 33 6 182 5 330 l-2 270 -95 3 c-66 2 -99 -1 -108 -10z"/>
<path d="M4672 7568 c-16 -16 -17 -337 0 -373 11 -24 15 -25 93 -25 59 0 86 4
94 14 24 29 40 363 19 384 -8 8 -46 12 -103 12 -57 0 -95 -4 -103 -12z"/>
<path d="M4582 7088 c-7 -7 -12 -26 -12 -43 0 -17 5 -36 12 -43 17 -17 1059
-17 1076 0 18 18 15 85 -4 92 -9 3 -251 6 -538 6 -393 0 -525 -3 -534 -12z"/>
<path d="M3420 6260 c-221 -20 -395 -93 -481 -201 -45 -57 -70 -132 -76 -224
-4 -73 -11 -100 -64 -232 -23 -60 -59 -1 -59 97 0 143 -46 242 -148 322 -101
77 -267 128 -435 131 l-102 2 -3 -41 c-8 -109 76 -329 158 -412 102 -104 170
-133 323 -140 l117 -4 20 -45 c31 -71 61 -233 43 -233 -4 0 -46 16 -93 37
-111 47 -210 57 -252 23 -16 -12 -28 -31 -28 -43 0 -21 151 -254 174 -269 6
-4 133 -8 283 -8 268 0 272 0 297 23 34 30 166 235 166 259 0 37 -36 58 -97
58 -61 0 -102 -11 -217 -54 -38 -14 -72 -24 -74 -21 -17 17 34 284 69 359 13
28 15 28 74 22 195 -19 372 84 460 270 62 130 95 314 59 328 -9 3 -19 5 -22 5
-4 -1 -45 -5 -92 -9z"/>
<path d="M7523 6005 c-228 -71 -328 -348 -192 -534 32 -46 40 -47 172 -34 46
4 67 10 67 19 0 8 -11 16 -24 20 -13 3 -34 16 -45 28 -19 20 -20 26 -9 50 10
24 16 27 42 21 17 -4 54 -8 81 -8 44 -2 50 0 50 18 0 15 -14 25 -60 41 -84 29
-125 69 -125 121 0 42 31 86 71 98 11 4 26 20 34 36 20 37 60 40 76 5 6 -13
29 -31 51 -39 36 -15 39 -19 36 -49 -3 -36 -4 -37 -100 -22 -39 5 -49 4 -54
-10 -9 -22 11 -43 49 -52 41 -8 83 -30 108 -55 14 -14 19 -33 19 -70 0 -53
-18 -81 -79 -120 -44 -28 -17 -39 95 -39 124 0 137 8 174 106 31 84 26 207
-13 287 -36 72 -119 149 -192 176 -65 24 -167 27 -232 6z"/>
<path d="M7005 5810 c-155 -32 -270 -160 -283 -315 l-5 -65 247 0 c135 0 246
3 246 7 0 3 -11 35 -25 69 -31 77 -43 195 -25 248 7 21 8 41 4 46 -14 12 -115
19 -159 10z"/>
<path d="M6440 5613 c-19 -8 -52 -31 -73 -51 -77 -74 -72 -19 -75 -776 -3
-770 -4 -754 69 -821 82 -74 29 -70 864 -73 413 -2 773 0 802 3 64 8 110 38
140 90 20 35 23 54 23 162 l0 121 -198 4 -198 3 -66 33 c-81 40 -139 102 -169
181 -17 45 -20 72 -17 137 4 69 9 90 39 145 41 73 101 125 182 154 44 16 80
19 237 20 l185 0 2 125 c3 114 1 128 -20 165 -46 80 -48 81 -379 89 -161 3
-518 4 -794 0 l-501 -6 -30 26 c-37 31 -48 67 -33 110 13 41 40 55 110 58 59
3 68 13 90 89 l9 29 -82 -1 c-49 0 -96 -7 -117 -16z"/>
<path d="M2438 4873 c-131 -119 -234 -252 -299 -389 -51 -106 -69 -181 -69
-280 0 -148 68 -239 214 -287 55 -18 95 -20 401 -24 187 -2 402 0 478 6 120 8
146 13 200 39 114 54 158 120 165 250 7 131 -29 243 -132 409 -54 87 -167 221
-237 282 l-37 31 -321 0 -321 0 -42 -37z"/>
<path d="M7775 4813 c-44 -23 -74 -52 -102 -101 -22 -37 -25 -51 -21 -115 5
-89 35 -143 105 -184 48 -28 48 -28 253 -30 235 -2 272 4 300 50 17 28 20 51
20 174 0 156 -8 186 -51 209 -43 22 -462 20 -504 -3z m168 -132 c30 -29 34
-100 8 -133 -28 -36 -82 -46 -126 -25 -68 32 -68 143 -1 173 39 18 94 11 119
-15z"/>
<path d="M4006 3709 c-44 -5 -105 -14 -135 -20 -117 -24 -323 -99 -443 -162
-158 -83 -605 -307 -613 -307 -3 0 -44 -17 -91 -37 -54 -23 -84 -42 -84 -52 0
-9 31 -92 69 -186 116 -284 400 -1007 421 -1073 7 -23 19 -47 25 -52 8 -7 58
12 161 61 198 95 228 106 319 118 139 20 207 5 815 -182 1002 -308 1026 -314
1156 -304 177 13 217 30 836 339 605 303 736 376 1063 593 315 209 812 550
868 596 52 42 78 98 79 167 2 166 -170 276 -376 241 -90 -15 -91 -15 -551
-245 -214 -107 -462 -225 -550 -262 -123 -53 -163 -74 -172 -93 -6 -13 -30
-64 -54 -114 -69 -146 -173 -229 -339 -271 -92 -23 -223 -19 -1016 37 -217 15
-269 10 -380 -36 -170 -71 -168 -70 -201 -56 -33 14 -63 58 -63 94 0 13 10 37
22 52 29 37 168 101 277 127 78 19 104 20 286 14 193 -6 443 -23 705 -47 173
-15 322 -8 382 19 194 87 249 317 110 460 -69 70 -120 90 -290 112 -81 11
-178 24 -217 30 -38 6 -167 22 -285 36 -330 39 -500 83 -720 185 -409 188
-700 251 -1014 218z"/>
<path d="M1988 3201 c-130 -53 -251 -106 -267 -119 -42 -31 -57 -85 -38 -137
50 -136 70 -188 96 -250 16 -38 57 -140 91 -225 34 -85 69 -173 79 -195 10
-22 24 -58 31 -80 7 -22 23 -65 36 -95 13 -30 71 -179 130 -330 58 -151 112
-288 119 -305 7 -16 27 -68 45 -115 36 -97 59 -126 111 -140 44 -12 16 -21
325 102 155 61 260 108 274 123 26 28 39 79 29 120 -6 25 -95 247 -128 320 -5
11 -15 36 -21 55 -7 19 -20 51 -28 70 -22 53 -26 64 -87 220 -31 80 -69 177
-85 215 -15 39 -46 117 -69 175 -22 58 -81 211 -132 340 -50 129 -96 248 -102
263 -5 16 -23 42 -39 58 -51 51 -95 43 -370 -70z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1583 1007" width="1583" height="1007"><style>.a{fill:#00b9f2}.b{fill:#021c41}</style><path class="a" d="m6.5 869.1h1576.5v137.3h-1576.5z"/><path fill-rule="evenodd" class="b" d="m428.3 0.8h-124.2l-303.9 728.7h171.8l59.4-141.8h269.5l59.4 141.8h171.8zm-149.3 448.5l87.1-227.4v0.1l0.1-0.1 87.1 227.4zm625.2-253.3h-149.8v533.4h163v-369.1c12.8-11.9 27.5-22.3 43.2-29.9 18.2-8.8 63.3-24.7 95.9 2.6 25.3 21.2 26.3 71.2 26.3 85.4v311h161.9v-319.5c0-19.5-2.2-38.7-5.3-57.9 14.8-10.1 31.5-17.3 47.8-24.5 37.1-16.5 79.5-8.3 93.3 1.3 29.9 20.8 32.8 68.1 32.8 89.6v311h161.9v-317.3c0-58.3-8.2-131.7-60.5-179.1-52.8-47.8-134.1-49-151.1-49.5-100.2 1.5-176 55.2-176 55.2-36.1-36.9-124.8-71.3-197.6-45.6-8 2.8-47.2 15.2-79.2 46.5z"/></svg>

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Created with Inkscape (http://www.inkscape.org/) by Marsupilami -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg789"
version="1.1"
width="1024"
height="404"
viewBox="-3.2371623 -3.2371623 289.9450246 114.3797346">
<defs
id="defs786" />
<path
id="path668"
d="m 275.83984,0 c -4.79,0 -7.62891,3.35 -7.62891,7 0,3.65 2.57891,7 7.62891,7 5.05,0 7.63086,-3.32 7.63086,-7 0,-3.63 -2.84086,-7 -7.63086,-7 z M 24.4707,1 0,59.7793 H 13.85156 L 18.64062,48.34961 H 40.36133 L 45.15039,59.7793 H 58.99023 L 34.4707,1 Z m 225.09961,0 v 15.61914 a 25.56,25.56 0 0 0 -6.5,-0.85938 c -8.43,0 -13,4.00016 -15,5.91016 a 22.84,22.84 0 0 0 -6.36914,15.89062 c -0.07,14.13 7.65023,19.73024 11.49023,21.49024 a 17.61,17.61 0 0 0 8.26953,1.63867 15.33,15.33 0 0 0 8.75,-3.50976 l 0.39063,2.60937 h 12 V 1 Z m -47.23438,14.7539 c -2.15572,-0.005 -4.10437,0.32063 -5.48437,0.79688 a 16.93,16.93 0 0 0 -6.38086,3.75 l -0.5293,-3.52149 h -12 V 59.7793 h 13.13868 V 30.11914 a 20.65,20.65 0 0 1 3.56054,-2.50977 8,8 0 0 1 7.43946,-0.17969 c 2.3,1.29 3.36132,4.32 3.36132,8 v 24.39063 h 13.13868 V 34.52929 c 0.01,-9.26 -3.39969,-13.38015 -5.42969,-15.16015 -3.05625,-2.68125 -7.22158,-3.60684 -10.81446,-3.61524 z m -92.52539,0.006 a 27.77,27.77 0 0 0 -14.18945,4.45117 c -2.91,-3 -10.04992,-5.74164 -15.91992,-3.68164 a 17,17 0 0 0 -6.38086,3.75 L 72.86132,16.76967 H 60.79101 v 43 H 73.93164 V 30.0001 a 14.17,14.17 0 0 1 3.47851,-2.41016 c 1.47,-0.71 5.10047,-1.99906 7.73047,0.21094 2,1.71 2.11914,5.7389 2.11914,6.8789 v 25.08009 h 13 V 34.0001 a 29.55,29.55 0 0 0 -0.42968,-4.66993 24.52,24.52 0 0 1 3.86132,-2 c 3,-1.33 6.39953,-0.67062 7.51953,0.10938 2.41,1.68 2.65039,5.4907 2.65039,7.2207 v 25.08008 h 13 V 34.19932 c 0,-4.7 -0.66085,-10.61946 -4.88085,-14.43946 -4.22,-3.82 -10.79993,-4 -12.16993,-4 z m 21.91993,1.00977 v 25.42969 c 0,4.77 0.85085,10.00062 4.13085,13.64062 3.28001,3.64 8.55993,5.10953 13.41993,4.76954 a 16.84,16.84 0 0 0 6.43945,-1.78907 14.55,14.55 0 0 0 3.86914,-2.7207 l 0.56055,3.68945 h 12 V 16.78916 l 0.041,-0.0195 h -13.14062 v 29.79101 a 22.79,22.79 0 0 1 -3.42969,2.13868 10.18,10.18 0 0 1 -7.34961,0.43164 c -3,-1.14 -3.41015,-6.00078 -3.41016,-8.05078 V 16.76966 Z m 137.58007,0 V 59.7794 h 13.05078 V 16.76963 Z m -239.83984,2.09957 7,18.33008 h -14 z M 244.00586,26.629 a 9.39,9.39 0 0 1 0.61523,0.002 15.47,15.47 0 0 1 5,0.88867 l -0.0508,19.25977 a 7.4,7.4 0 0 1 -3.19922,2.25 c -4.54,1.86 -10.34055,-0.23 -11.56055,-8 a 15.26,15.26 0 0 1 0.63086,-8.14844 9.39,9.39 0 0 1 8.56446,-6.25195 z"
style="fill:#0c1c49" />
<rect
style="fill:#0c1c49"
id="rect682"
height="11.07"
width="281.85999"
y="70.849991"
x="0.51071012"
class="cls-1" />
<path
id="path686"
d="m 81.51562,92.6875 a 7.22,7.22 0 0 0 -1.43554,0.14258 c -1.94,0.29 -3.81969,1.44976 -4.17969,3.50976 a 3.94,3.94 0 0 0 1.2207,3.75 11.13,11.13 0 0 0 3.85938,1.73047 7.75,7.75 0 0 1 1.7207,0.70899 1.18,1.18 0 0 1 0.48047,1.55078 c -0.29,0.53 -1.09063,0.66015 -1.64063,0.66015 a 3.76,3.76 0 0 1 -2,-0.4707 4.41,4.41 0 0 1 -1.26953,-1.04687 l -2.61133,2.01757 a 6.27,6.27 0 0 0 2.30078,1.83985 c 3.27,1.62 9.23969,0.70039 9.17969,-3.84961 a 4.09,4.09 0 0 0 -1.7207,-3.41016 15.09,15.09 0 0 0 -4,-1.59961 3.5,3.5 0 0 1 -1.61914,-0.88086 1,1 0 0 1 0.34961,-1.45898 2.91,2.91 0 0 1 1.91015,-0.18164 3.37,3.37 0 0 1 1.75,1 4.59,4.59 0 0 1 0.43946,0.54101 l 2.61132,-2 A 5.56,5.56 0 0 0 86.08008,94.32031 7.22,7.22 0 0 0 81.51562,92.6875 Z m -3.24414,10.53516 0.0684,-0.0527 -0.0996,0.0195 a 4.41,4.41 0 0 0 0.0312,0.0332 z M 94.23437,92.6875 a 7.22,7.22 0 0 0 -1.43359,0.14258 c -1.94,0.29 -3.81969,1.44976 -4.17969,3.50976 a 3.94,3.94 0 0 0 1.21875,3.75 11.13,11.13 0 0 0 3.86133,1.73047 7.75,7.75 0 0 1 1.71875,0.70899 1.18,1.18 0 0 1 0.48047,1.55078 c -0.29,0.54 -1.09063,0.66015 -1.64063,0.66015 a 3.75,3.75 0 0 1 -2,-0.4707 4.4,4.4 0 0 1 -1.27344,-1.05273 l -2.60546,2.01367 a 6.27,6.27 0 0 0 2.30078,1.83984 c 3.27,1.62 9.23969,0.70039 9.17968,-3.84961 a 4.09,4.09 0 0 0 -1.7207,-3.41015 15.06,15.06 0 0 0 -4,-1.59961 3.49,3.49 0 0 1 -1.61914,-0.88086 1,1 0 0 1 0.34961,-1.46094 2.9,2.9 0 0 1 1.91016,-0.17969 3.36,3.36 0 0 1 1.75,1 4.51,4.51 0 0 1 0.43945,0.54102 l 2.60938,-2 a 5.55,5.55 0 0 0 -0.7793,-0.91016 7.22,7.22 0 0 0 -4.56641,-1.63281 z m -3.24805,10.5293 0.0742,-0.0566 -0.0996,0.0293 a 4.4,4.4 0 0 0 0.0254,0.0274 z M 202.25976,92.74609 c -0.26877,0.01 -0.53633,0.032 -0.79883,0.0645 a 6.15,6.15 0 0 0 -4.83984,3.3789 9.58,9.58 0 0 0 -0.87109,4.78125 8,8 0 0 0 1.45117,4.42969 c 1.55,2.04 3.83039,2.64047 6.40039,2.48047 A 16.05,16.05 0 0 0 208.30078,107 v -7.41992 h -5.7207 v 2.88086 l 0.0215,0.0586 h 2.06836 v 2.12109 a 5.73,5.73 0 0 1 -2.71875,-0.10156 c -0.61,-0.25 -2.43141,-1.14937 -2.19141,-4.85937 a 4.41,4.41 0 0 1 1.15039,-3 2.4,2.4 0 0 1 3.08985,-0.21875 3.82,3.82 0 0 1 0.93164,1.28906 l 3.25,-1.66016 a 6.52,6.52 0 0 0 -0.85156,-1.39062 c -1.21626,-1.505 -3.18893,-2.02149 -5.07032,-1.95313 z M 65.21093,93 l -5.64062,14.51953 3.96094,0.0312 1,-2.5 h 4.8789 l 0.94141,2.5 h 4.17969 L 68.85156,93 Z m 36.98047,0 v 14.51953 h 10.79883 v -3.34961 h -6.90039 v -2.36914 h 5.25 v -3.07031 h -5.25 v -2.40039 h 6.45117 V 93 Z m 11.93946,0 v 3.33008 h 4.07031 v 11.18945 h 3.92969 V 96.33008 h 4 V 93 Z m 18.33984,0 v 14.51953 l 3.74023,0.01 v -7.13868 l 3,7.22852 h 1.58985 l 2.90039,-7.18945 v 7.09961 h 3.76953 V 93 h -4 l -3.38086,8.65039 L 136.4707,93 Z m 22.4707,0 -5.64062,14.51953 3.9707,0.0312 1,-2.5 h 4.86914 l 0.93946,2.5 h 4.17968 L 158.58008,93 Z m 11.10938,0 v 14.51953 l 3.81054,0.01 V 99.32054 L 175,107.52953 h 3.55078 V 93 h -3.83008 v 7.68945 L 169.99023,93 Z m 19.92969,0 -5.65039,14.51953 3.9707,0.0312 1,-2.5 h 4.88086 l 0.93945,2.5 h 4.17969 L 189.62109,93 Z m 25.13085,0 v 14.51953 h 10.8086 v -3.34961 h -6.89844 v -2.36914 h 5.25 v -3.07031 h -5.25 v -2.40039 h 6.44922 V 93 Z m 13.24024,0 v 14.51953 l 3.81836,0.01 v -7.13868 l 3,7.22852 h 1.55078 l 2.90039,-7.18945 v 7.09961 h 3.76953 V 93 h -4 L 231.9707,101.65039 228.35156,93 Z m 18.30859,0 v 14.51953 h 10.81055 v -3.34961 h -6.91016 v -2.36914 h 5.25 v -3.07031 h -5.25 v -2.40039 h 6.44922 V 93 Z m 13.25,0 v 14.51953 l 3.81055,0.01 v -8.20899 l 5.14062,8.20899 h 3.54883 V 93 h -3.83007 v 7.68945 L 259.85156,93 Z m 14.44141,0 v 3.33008 h 4.06836 v 11.18945 h 3.93164 V 96.33008 h 4 V 93 Z m -203.40039,4.63086 1.57031,4.41992 h -3.14062 z m 89.73047,0 1.56836,4.41992 h -3.13868 z m 31.03906,0 1.57031,4.41992 h -3.14062 z"
style="fill:#009fe3" />
</svg>
<!-- version: 20171223, original size: 283.4707 107.90541, border: 3% -->

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more