Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eea69d77a4 | ||
|
|
efa5de79b8 | ||
|
|
9591ae400e | ||
|
|
f507163369 | ||
|
|
bce89d8112 | ||
|
|
624d32ff7c | ||
|
|
1e3994f17f | ||
|
|
e0145c8727 | ||
|
|
add66a9310 | ||
|
|
b18ffc63ee | ||
|
|
1dcb33aa74 | ||
|
|
f2bdf29b16 | ||
|
|
e8e3ca34cc | ||
|
|
26197fc411 | ||
|
|
14cf4e5a19 | ||
|
|
a58a8460f7 | ||
|
|
49fab06012 | ||
|
|
8817ac693d | ||
|
|
c918de1e7d | ||
|
|
1d5737703b | ||
|
|
6bba34396c | ||
|
|
27c4c57a58 | ||
|
|
4cd4cbce71 | ||
|
|
d5792fb3e2 | ||
|
|
337d0c1a2c | ||
|
|
c2dbe506c5 | ||
|
|
0df335803e | ||
|
|
56d52d538e | ||
|
|
4b7df66862 | ||
|
|
6b6cad7cd6 | ||
|
|
be3c93897b | ||
|
|
bab7a79b63 | ||
|
|
8d104f2058 | ||
|
|
b3f5f80d2d | ||
|
|
f4d82e9687 | ||
|
|
ef6bc71b36 | ||
|
|
662003fdd3 | ||
|
|
3e8508d48b | ||
|
|
981ddf5830 | ||
|
|
0334a63139 | ||
|
|
9ea078302d | ||
|
|
0c99887846 | ||
|
|
33fee587aa | ||
|
|
8953d0bb35 | ||
|
|
f6829dec9d | ||
|
|
c7bbe5dee6 | ||
|
|
44b8e5eda0 | ||
|
|
7519b7507f | ||
|
|
e401530673 | ||
|
|
c9ca27b7be | ||
|
|
dc05e633c6 | ||
|
|
8e1b82d161 | ||
|
|
4ad6ea79e8 | ||
|
|
5dc6f21e61 | ||
|
|
5437091884 | ||
|
|
b48731ec50 | ||
|
|
6c96e023a9 | ||
|
|
9540221577 | ||
|
|
804a40f52c | ||
|
|
1060bfe2be | ||
|
|
6e03195d3a | ||
|
|
7ee011de09 | ||
|
|
b43754fe19 | ||
|
|
849e16de33 | ||
|
|
1bab97b5b3 | ||
|
|
37f9f8b8a7 | ||
|
|
014adaf22e | ||
|
|
330ccaa9d2 | ||
|
|
f76e7c9932 | ||
|
|
f4c590b3a3 | ||
|
|
5273f11e05 | ||
|
|
9ef32cef90 | ||
|
|
c2d5712e2c | ||
|
|
dc1863369c | ||
|
|
c5dc63cfe5 | ||
|
|
e95b0db5d5 | ||
|
|
ea01cf3205 | ||
|
|
6453218e2b | ||
|
|
eab9271b0f | ||
|
|
6f8f7bab01 | ||
|
|
b78fbbb101 |
No files matched your search
@@ -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
|
||||
@@ -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=
|
||||
@@ -8,6 +8,10 @@ build/
|
||||
*/dist/
|
||||
*/build/
|
||||
|
||||
# Résidus Vite (config transpilée temporairement lors de vite build/dev)
|
||||
vite.config.js.timestamp-*.mjs
|
||||
*/vite.config.js.timestamp-*.mjs
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
@@ -24,13 +26,34 @@
|
||||
"sharp": "^0.34.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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",
|
||||
@@ -666,15 +1129,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -705,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",
|
||||
@@ -876,9 +1347,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -900,15 +1371,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -1024,19 +1495,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1079,15 +1537,6 @@
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1189,18 +1638,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1422,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",
|
||||
@@ -1498,10 +1956,16 @@
|
||||
"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.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
|
||||
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1589,15 +2053,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -1781,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",
|
||||
@@ -1852,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",
|
||||
@@ -1916,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",
|
||||
@@ -1943,10 +2422,19 @@
|
||||
"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.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1965,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",
|
||||
@@ -2222,31 +2729,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz",
|
||||
"integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-finished": "~2.3.0",
|
||||
"on-finished": "~2.4.1",
|
||||
"on-headers": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan/node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -2550,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",
|
||||
@@ -2751,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",
|
||||
@@ -3053,16 +3610,14 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"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": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
@@ -3263,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",
|
||||
@@ -3362,24 +3923,6 @@
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
@@ -3401,19 +3944,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"name": "@e965/xlsx",
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@e965/xlsx/-/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
@@ -3488,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -27,7 +31,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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: [...] }.");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -87,7 +87,7 @@ CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- INVESTISSEMENTS (CF Investissements - liste des projets souscrits)
|
||||
-- statut: 'en_cours' | 'rembourse' | 'en_retard' | 'procedure' | 'cloture'
|
||||
-- statut: 'en_cours' | 'rembourse' | 'en_retard' | 'procedure' | 'cloture' | 'perte_definitive' | 'prolongation'
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS investissements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -104,7 +104,7 @@ CREATE TABLE IF NOT EXISTS investissements (
|
||||
type_remb TEXT, -- 'in_fine' | 'amortissable' | 'differe'
|
||||
freq_interets TEXT NOT NULL DEFAULT 'mensuel', -- 'mensuel' | 'trimestriel' | 'in_fine'
|
||||
statut TEXT NOT NULL DEFAULT 'en_cours'
|
||||
CHECK(statut IN ('en_cours','rembourse','en_retard','procedure','cloture')),
|
||||
CHECK(statut IN ('en_cours','rembourse','en_retard','procedure','cloture','perte_definitive','prolongation')),
|
||||
reference TEXT, -- ID projet sur la plateforme
|
||||
source TEXT NOT NULL DEFAULT 'manuel',
|
||||
notes TEXT,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,9 +28,14 @@ function notifyUser(userId, { type, title, body, link }) {
|
||||
|
||||
/**
|
||||
* Passe automatiquement au statut "en_retard" les investissements dont :
|
||||
* - le statut est actuellement "en_cours"
|
||||
* - le statut est actuellement "en_cours" OU "prolongation"
|
||||
* - la date_cible est renseignée et strictement antérieure à aujourd'hui
|
||||
*
|
||||
* Le cas "prolongation" couvre un dossier dont la date cible avait été repoussée via une
|
||||
* révision des conditions (renégociation suite à un retard) et qui retombe en retard parce
|
||||
* que la nouvelle échéance négociée est à son tour dépassée sans remboursement — cf.
|
||||
* POST /investissements/:id/revisions qui pose le statut 'prolongation'.
|
||||
*
|
||||
* Chaque passage est tracé dans investissement_historique avec le
|
||||
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité, et
|
||||
* génère une notification utilisateur.
|
||||
@@ -39,10 +44,10 @@ function notifyUser(userId, { type, title, body, link }) {
|
||||
*/
|
||||
function checkPassagesEnRetard() {
|
||||
const candidats = db.prepare(`
|
||||
SELECT i.id, i.nom_projet, i.date_cible, inv.user_id
|
||||
SELECT i.id, i.nom_projet, i.date_cible, i.statut, inv.user_id
|
||||
FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE i.statut = 'en_cours'
|
||||
WHERE i.statut IN ('en_cours', 'prolongation')
|
||||
AND i.date_cible IS NOT NULL
|
||||
AND i.date_cible < date('now')
|
||||
`).all();
|
||||
@@ -69,15 +74,19 @@ function checkPassagesEnRetard() {
|
||||
JSON.stringify([{
|
||||
champ: 'statut',
|
||||
label: 'Statut',
|
||||
ancienne_valeur: 'en_cours',
|
||||
ancienne_valeur: inv.statut,
|
||||
nouvelle_valeur: 'en_retard',
|
||||
}]),
|
||||
`Passage automatique : date cible (${inv.date_cible}) dépassée`
|
||||
inv.statut === 'prolongation'
|
||||
? `Passage automatique : la nouvelle échéance négociée (${inv.date_cible}) est à son tour dépassée`
|
||||
: `Passage automatique : date cible (${inv.date_cible}) dépassée`
|
||||
);
|
||||
notifyUser(inv.user_id, {
|
||||
type: 'warning',
|
||||
title: `Prêt en retard : "${inv.nom_projet}"`,
|
||||
body: `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`,
|
||||
body: inv.statut === 'prolongation'
|
||||
? `La date cible négociée (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt repasse automatiquement au statut "En retard".`
|
||||
: `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`,
|
||||
link: `/investissements/${inv.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 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, 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 });
|
||||
'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); }
|
||||
});
|
||||
|
||||
@@ -54,15 +54,22 @@ router.get('/', (req, res) => {
|
||||
`).all(...invParams, userId);
|
||||
|
||||
/* ── Solde porte-monnaie par plateforme ──────────────────────── */
|
||||
// ?asOf=YYYY-MM-DD (optionnel) : cumule uniquement jusqu'à cette date au lieu
|
||||
// de tout l'historique. Utilisé par le frontend pour le solde "à la fin de
|
||||
// l'année sélectionnée", en réutilisant exactement la même formule que le
|
||||
// solde live (source unique de vérité — cf. project_taxreport_interets_plateforme
|
||||
// en mémoire sur les bugs de duplication de cette formule côté frontend).
|
||||
const asOf = /^\d{4}-\d{2}-\d{2}$/.test(req.query.asOf || '') ? req.query.asOf : null;
|
||||
|
||||
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}
|
||||
WHERE ${invWhere}${asOf ? ' AND date_operation <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams);
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const rembWalletPerPlat = db.prepare(`
|
||||
SELECT i.plateforme_id,
|
||||
@@ -80,8 +87,9 @@ router.get('/', (req, res) => {
|
||||
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);
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const bonusWalletPerPlat = db.prepare(`
|
||||
SELECT bonus_plateforme_id AS plateforme_id,
|
||||
@@ -89,35 +97,97 @@ router.get('/', (req, res) => {
|
||||
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);
|
||||
`).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] : []));
|
||||
|
||||
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), 0)
|
||||
+ 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(...invParams);
|
||||
`).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,
|
||||
COALESCE(SUM(montant), 0) AS total_correction
|
||||
FROM corrections_solde
|
||||
WHERE ${invWhere}
|
||||
WHERE ${invWhere}${asOf ? ' AND date <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams);
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Fusion en une map plateforme_id → solde_portefeuille
|
||||
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 => ({
|
||||
@@ -129,6 +199,12 @@ router.get('/', (req, res) => {
|
||||
cashByPlatformEnriched.reduce((s, p) => s + p.solde_portefeuille, 0) * 100
|
||||
) / 100;
|
||||
|
||||
// Total des corrections de solde, toutes plateformes confondues (cumul complet
|
||||
// quand asOf est absent) — utilisé par le KPI "Gain net depuis le début" du Dashboard.
|
||||
const corrections_total = Math.round(
|
||||
correctionsPerPlat.reduce((s, r) => s + r.total_correction, 0) * 100
|
||||
) / 100;
|
||||
|
||||
/* ── Portfolio ───────────────────────────────────────────────── */
|
||||
const portfolio = db.prepare(`
|
||||
SELECT
|
||||
@@ -149,6 +225,10 @@ router.get('/', (req, res) => {
|
||||
`).get(...invParams);
|
||||
|
||||
/* ── Interets ────────────────────────────────────────────────── */
|
||||
// Inclut les remboursements normaux (liés à un investissement) et les
|
||||
// "Intérêts plateforme" (type=interets_plateforme, rattachés via
|
||||
// bonus_plateforme_id/bonus_investisseur_id comme les bonus, mais ce sont
|
||||
// de vrais intérêts fiscalisés — contrairement aux bonus qui restent exclus).
|
||||
const interets = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(r.interets_bruts),0) AS interets_bruts,
|
||||
@@ -156,9 +236,12 @@ router.get('/', (req, res) => {
|
||||
COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire,
|
||||
COALESCE(SUM(r.net_recu),0) AS net_recu
|
||||
FROM remboursements r
|
||||
JOIN investissements i ON i.id = r.investissement_id
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
`).get(...invParams);
|
||||
LEFT JOIN investissements i ON i.id = r.investissement_id
|
||||
WHERE (
|
||||
(r.investissement_id IS NOT NULL AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')})
|
||||
OR (r.investissement_id IS NULL AND r.type = 'interets_plateforme' AND ${invWhere.replace('investisseur_id', 'r.bonus_investisseur_id')})
|
||||
)
|
||||
`).get(...invParams, ...invParams);
|
||||
|
||||
/* ── Interets par annee ──────────────────────────────────────── */
|
||||
const interetsParAnnee = scopeAll
|
||||
@@ -212,7 +295,7 @@ router.get('/', (req, res) => {
|
||||
ORDER BY i.date_souscription DESC
|
||||
`).all(...invParams);
|
||||
|
||||
res.json({ cash, cashByPlatform: cashByPlatformEnriched, solde_portefeuille_total, portfolio, interets, interetsParAnnee, upcoming, enDefaut });
|
||||
res.json({ cash, cashByPlatform: cashByPlatformEnriched, solde_portefeuille_total, corrections_total, portfolio, interets, interetsParAnnee, upcoming, enDefaut });
|
||||
});
|
||||
|
||||
|
||||
@@ -304,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
|
||||
@@ -317,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;
|
||||
|
||||
@@ -618,6 +727,23 @@ router.get('/interets-par-plateforme', (req, res) => {
|
||||
ORDER BY p.nom, mois
|
||||
`).all(...invParams, anneeStr);
|
||||
|
||||
/* ── Intérêts plateforme (hors investissement) par plateforme et par mois ── */
|
||||
const interetsPlateformeByPlat = db.prepare(`
|
||||
SELECT
|
||||
r.bonus_plateforme_id AS plateforme_id,
|
||||
p.nom AS plateforme_nom,
|
||||
strftime('%Y-%m', r.date_remb) AS mois,
|
||||
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
|
||||
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets
|
||||
FROM remboursements r
|
||||
JOIN plateformes p ON p.id = r.bonus_plateforme_id
|
||||
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
|
||||
AND r.type = 'interets_plateforme'
|
||||
AND strftime('%Y', r.date_remb) = ?
|
||||
GROUP BY r.bonus_plateforme_id, p.nom, mois
|
||||
ORDER BY p.nom, mois
|
||||
`).all(...invParams, anneeStr);
|
||||
|
||||
/* ── Projections par plateforme et par mois ──────────────────── */
|
||||
// Exclure les investissements qui ont déjà un remboursement réel dans le même mois
|
||||
const projectionsByPlat = db.prepare(`
|
||||
@@ -651,7 +777,9 @@ router.get('/interets-par-plateforme', (req, res) => {
|
||||
i.date_souscription,
|
||||
i.date_cible,
|
||||
i.statut,
|
||||
(SELECT MAX(r.date_remb) FROM remboursements r WHERE r.investissement_id = i.id) AS last_remb_date
|
||||
(SELECT MAX(r.date_remb) FROM remboursements r WHERE r.investissement_id = i.id) AS last_remb_date,
|
||||
(SELECT ip.date_effet FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_date_effet,
|
||||
(SELECT ip.montant_perte FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_montant
|
||||
FROM investissements i
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
`).all(...invParams);
|
||||
@@ -739,10 +867,22 @@ router.get('/interets-par-plateforme', (req, res) => {
|
||||
.reduce((s, r) => s + r.montant, 0);
|
||||
const capitalRestant = Math.max(0, inv.montant_investi + reinvestsCumul - capitalRembCumul - capitalSimulCumul);
|
||||
|
||||
const actif = ['en_cours', 'en_retard', 'procedure'].includes(inv.statut);
|
||||
// 'prolongation' compte normalement comme capital encours (comme en_retard/procedure
|
||||
// dont il découle après renégociation) mais n'entre pas dans en_defaut : il est sorti
|
||||
// temporairement du scope "à risque" tant que la nouvelle échéance n'est pas dépassée.
|
||||
const actif = ['en_cours', 'en_retard', 'procedure', 'prolongation'].includes(inv.statut);
|
||||
if (actif) {
|
||||
capital += capitalRestant;
|
||||
if (['en_retard', 'procedure'].includes(inv.statut)) enDefaut += capitalRestant;
|
||||
} else if (inv.statut === 'perte_definitive' && inv.perte_date_effet) {
|
||||
// Avant la déclaration de perte (fidélité historique) : capital compté normalement,
|
||||
// comme si le dossier était encore en_retard/procedure ce mois-là. À partir du mois
|
||||
// de déclaration : capital réduit du montant acté en perte (perte partielle possible).
|
||||
if (lastDay < inv.perte_date_effet) {
|
||||
capital += capitalRestant;
|
||||
} else {
|
||||
capital += Math.max(0, capitalRestant - (inv.perte_montant || 0));
|
||||
}
|
||||
} else {
|
||||
// Remboursé/clôturé : était-il encore actif ce mois-ci ?
|
||||
// Priorité : date_cible → dernier remboursement réel → exclure
|
||||
@@ -782,6 +922,17 @@ router.get('/interets-par-plateforme', (req, res) => {
|
||||
rembMap[b.plateforme_id].rembourses[b.mois] = { interets_bruts: 0, interets_nets: 0, cashback: b.cashback, capital: 0 };
|
||||
}
|
||||
}
|
||||
// Ajouter les intérêts plateforme (hors investissement) aux remboursements par mois
|
||||
for (const ip of interetsPlateformeByPlat) {
|
||||
if (!rembMap[ip.plateforme_id]) rembMap[ip.plateforme_id] = { id: ip.plateforme_id, nom: ip.plateforme_nom, detenteur_nom: detenteurMap[ip.plateforme_id] ?? null, rembourses: {}, projections: {} };
|
||||
const entry = rembMap[ip.plateforme_id].rembourses[ip.mois];
|
||||
if (entry) {
|
||||
entry.interets_bruts = (entry.interets_bruts ?? 0) + ip.interets_bruts;
|
||||
entry.interets_nets = (entry.interets_nets ?? 0) + ip.interets_nets;
|
||||
} else {
|
||||
rembMap[ip.plateforme_id].rembourses[ip.mois] = { interets_bruts: ip.interets_bruts, interets_nets: ip.interets_nets, cashback: 0, capital: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of projectionsByPlat) {
|
||||
if (!rembMap[p.plateforme_id]) rembMap[p.plateforme_id] = { id: p.plateforme_id, nom: p.plateforme_nom, detenteur_nom: detenteurMap[p.plateforme_id] ?? null, rembourses: {}, projections: {} };
|
||||
@@ -845,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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import xlsx from 'xlsx';
|
||||
// @e965/xlsx (fork npm de SheetJS, cf. project_npm_audit_dependances.md) expose un build ESM qui ne détecte pas
|
||||
// automatiquement fs.readFileSync/writeFileSync sous Node (contrairement à l'ancien 'xlsx' CJS) : set_fs est requis.
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
xlsx.set_fs(fs);
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
|
||||
|
||||
@@ -23,19 +23,36 @@ 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 || '',
|
||||
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); }
|
||||
});
|
||||
@@ -43,16 +60,20 @@ router.get('/', (_req, res, next) => {
|
||||
const PatchSchema = z.object({
|
||||
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
|
||||
@@ -60,7 +81,9 @@ router.patch('/', (req, res, next) => {
|
||||
app_url = ?,
|
||||
mcp_url = ?,
|
||||
allow_registration = ?,
|
||||
min_password_length = ?
|
||||
min_password_length = ?,
|
||||
documents_quota_bytes = ?,
|
||||
documents_quota_count = ?
|
||||
WHERE id = 1
|
||||
`).run(
|
||||
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
|
||||
@@ -68,8 +91,17 @@ router.patch('/', (req, res, next) => {
|
||||
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); }
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import xlsx from 'xlsx';
|
||||
// @e965/xlsx (fork npm de SheetJS, cf. project_npm_audit_dependances.md) expose un build ESM qui ne détecte pas
|
||||
// automatiquement fs.readFileSync/writeFileSync sous Node (contrairement à l'ancien 'xlsx' CJS) : set_fs est requis.
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
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();
|
||||
|
||||
@@ -21,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)
|
||||
@@ -345,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++) {
|
||||
@@ -380,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,
|
||||
@@ -391,6 +409,7 @@ router.post('/apply', (req, res, next) => {
|
||||
v('libelle') || null,
|
||||
v('reference') || null,
|
||||
srcLabel,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
} else if (module === 'investissements') {
|
||||
@@ -454,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);
|
||||
|
||||
@@ -623,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 ─────────────────── */
|
||||
@@ -669,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,
|
||||
@@ -679,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);
|
||||
|
||||
@@ -687,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -760,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,
|
||||
@@ -768,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,
|
||||
);
|
||||
|
||||
@@ -783,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++;
|
||||
}
|
||||
@@ -845,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 ──────────────────────────────────────── */
|
||||
@@ -861,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;
|
||||
@@ -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 } from '../utils/schedule.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();
|
||||
|
||||
@@ -61,20 +65,54 @@ const RevisionSchema = z.object({
|
||||
message: 'Au moins un changement (nouveau taux ou nouvelle date cible) doit être renseigné',
|
||||
});
|
||||
|
||||
// Déclaration d'une perte définitive de capital sur un investissement en_retard/procedure.
|
||||
// montant_perte est éditable (perte partielle possible si une partie du capital a déjà
|
||||
// été recouvrée) — préfilli côté frontend avec le capital restant dû, 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'),
|
||||
});
|
||||
|
||||
function regenererEcheancier(investissementId) {
|
||||
const inv = db.prepare(`
|
||||
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois,
|
||||
date_cible
|
||||
FROM investissements WHERE id = ?
|
||||
`).get(investissementId);
|
||||
if (!inv) return;
|
||||
|
||||
const hasReinvest = db.prepare(
|
||||
'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1'
|
||||
).get(investissementId);
|
||||
if (hasReinvest) {
|
||||
generateSimulWithReinvestissements(db, investissementId);
|
||||
} else {
|
||||
const inv = db.prepare(`
|
||||
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
|
||||
FROM investissements WHERE id = ?
|
||||
`).get(investissementId);
|
||||
generateSimul(db, inv);
|
||||
}
|
||||
|
||||
// generateSimul()/generateSimulWithReinvestissements() reconstruisent l'échéancier à partir
|
||||
// du montant_investi ORIGINAL, sans connaître les remboursements anticipés partiels déjà
|
||||
// effectués — il faut donc systématiquement réappliquer l'ajustement de capital réel après
|
||||
// toute régénération, sous peine de l'écraser silencieusement :
|
||||
// - Restructuration (date_debut_simul renseigné, création d'une révision) : seules de
|
||||
// NOUVELLES échéances futures sont insérées, les échéances déjà honorées ("kept") sont
|
||||
// préservées telles quelles par generateSimul() lui-même -> un simple adjustSimulForActuals()
|
||||
// suffit à recalculer ces nouvelles échéances sur la base du capital réellement restant dû.
|
||||
// - Régénération complète (annulation d'une révision, ou toute autre modif hors
|
||||
// restructuration) : TOUT l'échéancier est réécrit à neuf -> adjustSimulForActuals() seul
|
||||
// ne peut PAS reconstruire l'historique (il ne recalcule que ce qui suit le DERNIER
|
||||
// remboursement connu, jamais une valeur intermédiaire déjà "gelée" par un remboursement
|
||||
// plus récent) -> il faut rejouer chronologiquement tout l'historique de remboursements
|
||||
// de capital anticipés via replayAdjustSimulForActuals().
|
||||
// Bug découvert le 22/08/26 sur le dossier "L'Olympique" : l'annulation d'une révision avait
|
||||
// effacé l'intégralité de l'ajustement de capital anticipé accumulé sur 18 échéances.
|
||||
if (inv.date_debut_simul) {
|
||||
adjustSimulForActuals(db, investissementId);
|
||||
} else {
|
||||
replayAdjustSimulForActuals(db, investissementId);
|
||||
}
|
||||
}
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -135,6 +173,8 @@ router.get('/', (req, res) => {
|
||||
(SELECT COALESCE(SUM(r.interets_nets),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_nets_total,
|
||||
(SELECT COALESCE(SUM(r.net_recu),0) FROM remboursements r WHERE r.investissement_id = i.id) AS net_recu_total,
|
||||
(SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS reinvestissements_total,
|
||||
(SELECT COALESCE(SUM(ip.montant_perte),0) FROM investissement_pertes ip WHERE ip.investissement_id = i.id) AS capital_perdu,
|
||||
(SELECT ip.date_effet FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_date_effet,
|
||||
i.montant_investi + (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS capital_total
|
||||
FROM investissements i
|
||||
JOIN plateformes p ON p.id = i.plateforme_id
|
||||
@@ -185,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); }
|
||||
@@ -224,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
|
||||
@@ -287,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,
|
||||
@@ -336,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
|
||||
@@ -353,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;
|
||||
|
||||
@@ -369,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',
|
||||
@@ -381,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)",
|
||||
});
|
||||
@@ -390,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -425,6 +485,9 @@ router.get('/:id', (req, res, next) => {
|
||||
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);
|
||||
@@ -445,7 +508,174 @@ router.get('/:id', (req, res, next) => {
|
||||
WHERE is2.investissement_id = ?
|
||||
ORDER BY is_global DESC, s.nom
|
||||
`).all(req.params.id);
|
||||
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, reinvestissements, categories_inv, secteurs_inv });
|
||||
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, pertes, reinvestissements, categories_inv, secteurs_inv });
|
||||
} 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); }
|
||||
});
|
||||
|
||||
@@ -578,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 = ?)
|
||||
@@ -611,6 +847,10 @@ router.delete('/:id/historique/:hid', (req, res, next) => {
|
||||
// qui n'est qu'un champ d'affichage/contractuel). Donc si nouvelle_date_cible est fournie et
|
||||
// diffère de la date actuelle, duree_mois doit être recalculé en conséquence, sinon la
|
||||
// nouvelle date cible ne serait que cosmétique et l'échéancier généré ne la refléterait pas.
|
||||
//
|
||||
// Statut "Prolongation" : si la date cible change réellement sur un dossier en_retard/procedure,
|
||||
// le statut bascule automatiquement à 'prolongation' (sort temporairement du scope "à risque",
|
||||
// jusqu'à ce que jobs/autoStatut.js le repasse en_retard si la nouvelle échéance est dépassée).
|
||||
router.post('/:id/revisions', (req, res, next) => {
|
||||
try {
|
||||
const body = RevisionSchema.parse(req.body);
|
||||
@@ -630,28 +870,56 @@ router.post('/:id/revisions', (req, res, next) => {
|
||||
|
||||
// Recalcule duree_mois si la date cible change réellement, pour que l'échéancier régénéré
|
||||
// atteigne effectivement cette nouvelle date (cf. convention date_cible = date_premiere_echeance
|
||||
// + (duree_mois - 1) mois, utilisée côté frontend pour tous les types de prêt).
|
||||
// + (duree_mois - 1) mois, utilisée côté frontend pour les prêts à échéances multiples).
|
||||
//
|
||||
// Cas particulier "differe" (versement unique) : duree_mois y représente le nombre total de
|
||||
// mois entre date_souscription et l'unique échéance (cf. buildSchedule(), qui l'utilise
|
||||
// directement dans interets = montant * taux/100/12 * duree — même convention qu'à la
|
||||
// création du prêt), PAS un décompte d'échéances depuis date_premiere_echeance. Réutiliser
|
||||
// la formule "+1 depuis date_premiere_echeance" ici sous-évalue lourdement la durée réelle
|
||||
// (et donc les intérêts) — bug signalé par Olivier le 22/08/26 sur "Le Troubadour" : une
|
||||
// prolongation de 12%→17% jusqu'au 30/09/2026 donnait duree_mois=8 (mois écoulés entre
|
||||
// date_premiere_echeance et la nouvelle date cible, +1) au lieu de 25 (mois écoulés depuis
|
||||
// la souscription), d'où des intérêts largement sous-estimés (28,33€ au lieu de ~88,54€).
|
||||
const dateChangee = nouvelle_date_cible !== ancienne_date_cible;
|
||||
let nouveau_duree_mois = ancien_duree_mois;
|
||||
if (nouvelle_date_cible !== ancienne_date_cible && inv.date_premiere_echeance) {
|
||||
if (dateChangee) {
|
||||
if (inv.type_remb === 'differe' && inv.date_souscription) {
|
||||
nouveau_duree_mois = monthsDiff(inv.date_souscription, nouvelle_date_cible);
|
||||
} else if (inv.date_premiere_echeance) {
|
||||
nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Statut "Prolongation" : uniquement quand la date cible change réellement ET que le
|
||||
// dossier est actuellement en_retard/procedure (renégociation d'un prêt à risque). Un
|
||||
// changement de taux seul, ou une révision sur un dossier en_cours, ne déclenche jamais
|
||||
// ce statut — décision confirmée avec Olivier le 22/08/26. Le dossier sort ainsi
|
||||
// temporairement du scope "à risque" jusqu'à ce que jobs/autoStatut.js le repasse en
|
||||
// en_retard si la nouvelle date cible est à son tour dépassée sans remboursement.
|
||||
const RISQUE_STATUTS = ['en_retard', 'procedure'];
|
||||
const ancien_statut = (dateChangee && RISQUE_STATUTS.includes(inv.statut)) ? inv.statut : null;
|
||||
const nouveau_statut = ancien_statut ? 'prolongation' : null;
|
||||
|
||||
let revisionId;
|
||||
const tx = db.transaction(() => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO investissement_revisions
|
||||
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
|
||||
ancien_duree_mois, nouveau_duree_mois, motif)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ancien_duree_mois, nouveau_duree_mois, ancien_statut, nouveau_statut, motif)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(invId, body.date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
|
||||
ancien_duree_mois, nouveau_duree_mois, body.motif);
|
||||
ancien_duree_mois, nouveau_duree_mois, ancien_statut, nouveau_statut, body.motif);
|
||||
revisionId = r.lastInsertRowid;
|
||||
|
||||
// COALESCE(?, statut) : nouveau_statut est NULL quand la révision ne doit pas changer
|
||||
// le statut (taux seul, dossier déjà sain…) — le statut courant est alors préservé.
|
||||
db.prepare(`
|
||||
UPDATE investissements
|
||||
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, updated_at = datetime('now')
|
||||
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?,
|
||||
statut = COALESCE(?, statut), updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, invId);
|
||||
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, nouveau_statut, invId);
|
||||
|
||||
regenererEcheancier(invId);
|
||||
});
|
||||
@@ -670,7 +938,7 @@ router.delete('/:id/revisions/:rid', (req, res, next) => {
|
||||
try {
|
||||
const invId = Number(req.params.id);
|
||||
const inv = db.prepare(`
|
||||
SELECT i.id FROM investissements i
|
||||
SELECT i.id, i.statut FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE i.id = ? AND inv.user_id = ?
|
||||
`).get(invId, req.user.id);
|
||||
@@ -683,17 +951,25 @@ router.delete('/:id/revisions/:rid', (req, res, next) => {
|
||||
throw new HttpError(400, 'Seule la révision la plus récente peut être supprimée');
|
||||
}
|
||||
|
||||
// Ne restaure le statut que si rien ne l'a fait évoluer depuis cette révision (ex : le
|
||||
// job autoStatut a déjà repassé le dossier en_retard entre-temps) — sinon on écraserait
|
||||
// un état plus récent et légitime avec l'ancien statut de la révision annulée.
|
||||
const statutRestore = (derniere.nouveau_statut && inv.statut === derniere.nouveau_statut)
|
||||
? derniere.ancien_statut
|
||||
: null;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE investissements
|
||||
SET taux_interet = ?, date_cible = ?, duree_mois = ?,
|
||||
statut = COALESCE(?, statut),
|
||||
date_debut_simul = (
|
||||
SELECT date_effet FROM investissement_revisions
|
||||
WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1
|
||||
),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, invId, derniere.id, invId);
|
||||
`).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, statutRestore, invId, derniere.id, invId);
|
||||
|
||||
db.prepare('DELETE FROM investissement_revisions WHERE id = ?').run(derniere.id);
|
||||
|
||||
@@ -705,6 +981,170 @@ router.delete('/:id/revisions/:rid', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// PUT /api/investissements/:id/revisions/:rid
|
||||
// Corrige la révision la plus récente en place (même ligne d'historique conservée), plutôt que
|
||||
// de la supprimer puis d'en recréer une — évite un aller-retour et garde une trace stable dans
|
||||
// l'historique. ancien_taux/ancienne_date_cible/ancien_duree_mois restent figés sur l'état
|
||||
// D'AVANT CETTE RÉVISION (jamais modifiés par une correction) ; seuls les nouveau_* et le motif
|
||||
// sont mis à jour. Même contrainte que la suppression : seule la révision la plus récente peut
|
||||
// être corrigée, pour éviter un état incohérent si une révision intermédiaire était éditée.
|
||||
router.put('/:id/revisions/:rid', (req, res, next) => {
|
||||
try {
|
||||
const body = RevisionSchema.parse(req.body);
|
||||
const invId = Number(req.params.id);
|
||||
const inv = db.prepare(`
|
||||
SELECT i.id, i.statut, i.date_premiere_echeance, i.type_remb, i.date_souscription FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE i.id = ? AND inv.user_id = ?
|
||||
`).get(invId, req.user.id);
|
||||
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
||||
|
||||
const derniere = db.prepare(
|
||||
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id DESC LIMIT 1'
|
||||
).get(invId);
|
||||
if (!derniere || derniere.id !== Number(req.params.rid)) {
|
||||
throw new HttpError(400, 'Seule la révision la plus récente peut être corrigée');
|
||||
}
|
||||
|
||||
// Baseline inchangée : l'état d'AVANT cette révision (jamais retouché par une correction).
|
||||
const ancien_taux = derniere.ancien_taux;
|
||||
const ancienne_date_cible = derniere.ancienne_date_cible;
|
||||
const ancien_duree_mois = derniere.ancien_duree_mois;
|
||||
const nouveau_taux = body.nouveau_taux ?? ancien_taux;
|
||||
const nouvelle_date_cible = body.nouvelle_date_cible ?? ancienne_date_cible;
|
||||
|
||||
// Cf. commentaire équivalent dans POST /:id/revisions : un prêt "differe" (versement
|
||||
// unique) recalcule duree_mois depuis date_souscription (sans +1), jamais depuis
|
||||
// date_premiere_echeance comme les prêts à échéances multiples.
|
||||
const dateChangee = nouvelle_date_cible !== ancienne_date_cible;
|
||||
let nouveau_duree_mois = ancien_duree_mois;
|
||||
if (dateChangee) {
|
||||
if (inv.type_remb === 'differe' && inv.date_souscription) {
|
||||
nouveau_duree_mois = monthsDiff(inv.date_souscription, nouvelle_date_cible);
|
||||
} else if (inv.date_premiere_echeance) {
|
||||
nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Statut : on rejoue le même raisonnement que POST/DELETE combinés — d'abord retrouver le
|
||||
// statut d'avant l'effet de cette révision (sauf si autre chose l'a fait évoluer entre
|
||||
// temps, ex. autoStatut), puis réévaluer si la version CORRIGÉE doit déclencher Prolongation.
|
||||
const RISQUE_STATUTS = ['en_retard', 'procedure'];
|
||||
const statutBase = (derniere.nouveau_statut && inv.statut === derniere.nouveau_statut)
|
||||
? derniere.ancien_statut
|
||||
: inv.statut;
|
||||
const ancien_statut = (dateChangee && RISQUE_STATUTS.includes(statutBase)) ? statutBase : null;
|
||||
const nouveau_statut = ancien_statut ? 'prolongation' : null;
|
||||
const statutFinal = nouveau_statut ?? statutBase;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE investissement_revisions
|
||||
SET date_effet = ?, nouveau_taux = ?, nouvelle_date_cible = ?, nouveau_duree_mois = ?,
|
||||
ancien_statut = ?, nouveau_statut = ?, motif = ?
|
||||
WHERE id = ?
|
||||
`).run(body.date_effet, nouveau_taux, nouvelle_date_cible, nouveau_duree_mois,
|
||||
ancien_statut, nouveau_statut, body.motif, derniere.id);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE investissements
|
||||
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?,
|
||||
statut = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, statutFinal, invId);
|
||||
|
||||
regenererEcheancier(invId);
|
||||
});
|
||||
tx();
|
||||
|
||||
const revision = db.prepare('SELECT * FROM investissement_revisions WHERE id = ?').get(derniere.id);
|
||||
res.json(revision);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// POST /api/investissements/:id/perte-definitive
|
||||
// Déclare qu'un investissement en_retard/procedure est définitivement perdu (irrécouvrable).
|
||||
// Le montant est renseigné explicitement (perte totale ou partielle si récupération partielle)
|
||||
// plutôt que recalculé côté serveur, pour laisser l'utilisateur ajuster au moment du constat.
|
||||
// Trace l'ancien statut dans investissement_pertes pour permettre une annulation propre.
|
||||
router.post('/:id/perte-definitive', (req, res, next) => {
|
||||
try {
|
||||
const body = PerteSchema.parse(req.body);
|
||||
const invId = Number(req.params.id);
|
||||
const inv = db.prepare(`
|
||||
SELECT i.* FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE i.id = ? AND inv.user_id = ?
|
||||
`).get(invId, req.user.id);
|
||||
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
||||
if (!['en_retard', 'procedure'].includes(inv.statut)) {
|
||||
throw new HttpError(400, 'Seuls les dossiers "En retard" ou "Procédure" peuvent être déclarés en perte définitive');
|
||||
}
|
||||
|
||||
let perteId;
|
||||
const tx = db.transaction(() => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO investissement_pertes (investissement_id, date_effet, montant_perte, ancien_statut, motif)
|
||||
VALUES (?,?,?,?,?)
|
||||
`).run(invId, body.date_effet, body.montant_perte, inv.statut, body.motif);
|
||||
perteId = r.lastInsertRowid;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE investissements SET statut = 'perte_definitive', updated_at = datetime('now') WHERE id = ?
|
||||
`).run(invId);
|
||||
|
||||
recordHistory(invId, {
|
||||
type_evenement: 'perte_definitive',
|
||||
changements: [{ champ: 'statut', label: 'Statut', ancienne_valeur: inv.statut, nouvelle_valeur: 'perte_definitive' }],
|
||||
notes: `Perte définitive déclarée (${body.montant_perte.toFixed(2)} €) : ${body.motif}`,
|
||||
});
|
||||
});
|
||||
tx();
|
||||
|
||||
const perte = db.prepare('SELECT * FROM investissement_pertes WHERE id = ?').get(perteId);
|
||||
res.status(201).json(perte);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// DELETE /api/investissements/:id/perte-definitive
|
||||
// Annule la déclaration de perte la plus récente et restaure le statut précédent
|
||||
// (en_retard ou procédure) — le dossier redevient actif au sens fiscal/KPI.
|
||||
router.delete('/:id/perte-definitive', (req, res, next) => {
|
||||
try {
|
||||
const invId = Number(req.params.id);
|
||||
const inv = db.prepare(`
|
||||
SELECT i.* FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE i.id = ? AND inv.user_id = ?
|
||||
`).get(invId, req.user.id);
|
||||
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
||||
if (inv.statut !== 'perte_definitive') {
|
||||
throw new HttpError(400, "Ce dossier n'est pas déclaré en perte définitive");
|
||||
}
|
||||
|
||||
const derniere = db.prepare(
|
||||
'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id DESC LIMIT 1'
|
||||
).get(invId);
|
||||
const statutRestaure = derniere?.ancien_statut || 'en_retard';
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (derniere) db.prepare('DELETE FROM investissement_pertes WHERE id = ?').run(derniere.id);
|
||||
db.prepare(`
|
||||
UPDATE investissements SET statut = ?, updated_at = datetime('now') WHERE id = ?
|
||||
`).run(statutRestaure, invId);
|
||||
|
||||
recordHistory(invId, {
|
||||
type_evenement: 'annulation_perte_definitive',
|
||||
changements: [{ champ: 'statut', label: 'Statut', ancienne_valeur: 'perte_definitive', nouvelle_valeur: statutRestaure }],
|
||||
notes: 'Annulation de la déclaration de perte définitive',
|
||||
});
|
||||
});
|
||||
tx();
|
||||
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// PUT /api/investissements/:id/auto-reinvest { active: true|false }
|
||||
router.put('/:id/auto-reinvest', (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -6,11 +6,22 @@ const router = Router();
|
||||
|
||||
const TYPE_DEFAUT = 'versement_annuel';
|
||||
|
||||
/** Valide et normalise le corps d'une requête POST */
|
||||
/**
|
||||
* Valide et normalise le corps d'une requête POST.
|
||||
* - type='versement_annuel' (défaut) : objectif global, investisseur_id requis, plateforme_id absent.
|
||||
* - type='capital_investi' : objectif par plateforme, plateforme_id requis ; investisseur_id est
|
||||
* dérivé de la plateforme (ignoré s'il est fourni) car une plateforme appartient à un seul
|
||||
* investisseur — cf. section Paramétrage > Fixation des objectifs.
|
||||
*/
|
||||
function parseBody(body) {
|
||||
const { investisseur_id, type = TYPE_DEFAUT, annee, montant, notes } = body;
|
||||
if (!investisseur_id) throw new HttpError(400, 'investisseur_id est requis');
|
||||
const { investisseur_id, plateforme_id, type = TYPE_DEFAUT, annee, montant, notes } = body;
|
||||
if (!type?.trim()) throw new HttpError(400, 'type est requis');
|
||||
const trimmedType = type.trim();
|
||||
if (trimmedType === 'capital_investi') {
|
||||
if (!plateforme_id) throw new HttpError(400, 'plateforme_id est requis pour un objectif capital_investi');
|
||||
} else if (!investisseur_id) {
|
||||
throw new HttpError(400, 'investisseur_id est requis');
|
||||
}
|
||||
if (!Number.isInteger(Number(annee)) || Number(annee) < 2000 || Number(annee) > 2100) {
|
||||
throw new HttpError(400, 'annee invalide');
|
||||
}
|
||||
@@ -18,24 +29,26 @@ function parseBody(body) {
|
||||
throw new HttpError(400, 'montant doit être un nombre positif');
|
||||
}
|
||||
return {
|
||||
investisseur_id: Number(investisseur_id),
|
||||
type: type.trim(),
|
||||
investisseur_id: investisseur_id ? Number(investisseur_id) : null,
|
||||
plateforme_id: plateforme_id ? Number(plateforme_id) : null,
|
||||
type: trimmedType,
|
||||
annee: Number(annee),
|
||||
montant: Number(montant),
|
||||
notes: notes?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── GET /api/objectifs?type=&annee= ─────────────────────────────────────
|
||||
/* ── GET /api/objectifs?type=&annee=&plateforme_id= ──────────────────────
|
||||
Retourne tous les objectifs des investisseurs de l'utilisateur connecté
|
||||
(toutes plateformes / tout le portefeuille — pas de notion de scope=all
|
||||
vs single ici, un objectif appartient toujours à un investisseur donné). */
|
||||
router.get('/', (req, res) => {
|
||||
const { type, annee } = req.query;
|
||||
const { type, annee, plateforme_id } = req.query;
|
||||
const conds = ['i.user_id = ?'];
|
||||
const args = [req.user.id];
|
||||
if (type) { conds.push('o.type = ?'); args.push(type); }
|
||||
if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); }
|
||||
if (plateforme_id) { conds.push('o.plateforme_id = ?'); args.push(Number(plateforme_id)); }
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT o.*, i.nom AS investisseur_nom
|
||||
@@ -47,26 +60,53 @@ router.get('/', (req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
/* ── POST /api/objectifs ── upsert (investisseur_id, type, annee) ───────── */
|
||||
/* ── POST /api/objectifs ── upsert manuel (select puis update/insert) ─────
|
||||
Pas de ON CONFLICT : deux index uniques partiels différents selon le type
|
||||
(cf. db/index.js), plus simple et plus sûr de faire l'upsert à la main que
|
||||
de viser le bon index partiel en SQL. */
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const data = parseBody(req.body);
|
||||
|
||||
let investisseurId = data.investisseur_id;
|
||||
if (data.type === 'capital_investi') {
|
||||
const plat = db.prepare('SELECT id, investisseur_id FROM plateformes WHERE id = ? AND user_id = ?')
|
||||
.get(data.plateforme_id, req.user.id);
|
||||
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
|
||||
investisseurId = plat.investisseur_id;
|
||||
} else {
|
||||
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
|
||||
.get(data.investisseur_id, req.user.id);
|
||||
.get(investisseurId, req.user.id);
|
||||
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
|
||||
}
|
||||
|
||||
const existing = data.plateforme_id
|
||||
? db.prepare('SELECT id FROM objectifs WHERE plateforme_id = ? AND type = ? AND annee = ?')
|
||||
.get(data.plateforme_id, data.type, data.annee)
|
||||
: db.prepare('SELECT id FROM objectifs WHERE investisseur_id = ? AND type = ? AND annee = ? AND plateforme_id IS NULL')
|
||||
.get(investisseurId, data.type, data.annee);
|
||||
|
||||
if (existing) {
|
||||
db.prepare(`UPDATE objectifs SET montant = ?, notes = ?, updated_at = datetime('now') WHERE id = ?`)
|
||||
.run(data.montant, data.notes, existing.id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO objectifs (investisseur_id, type, annee, montant, notes)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(investisseur_id, type, annee)
|
||||
DO UPDATE SET montant = excluded.montant, notes = excluded.notes, updated_at = datetime('now')
|
||||
`).run(data.investisseur_id, data.type, data.annee, data.montant, data.notes);
|
||||
INSERT INTO objectifs (investisseur_id, plateforme_id, type, annee, montant, notes)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`).run(investisseurId, data.plateforme_id, data.type, data.annee, data.montant, data.notes);
|
||||
}
|
||||
|
||||
const saved = db.prepare(`
|
||||
const saved = data.plateforme_id
|
||||
? db.prepare(`
|
||||
SELECT o.*, i.nom AS investisseur_nom
|
||||
FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id
|
||||
WHERE o.investisseur_id = ? AND o.type = ? AND o.annee = ?
|
||||
`).get(data.investisseur_id, data.type, data.annee);
|
||||
WHERE o.plateforme_id = ? AND o.type = ? AND o.annee = ?
|
||||
`).get(data.plateforme_id, data.type, data.annee)
|
||||
: db.prepare(`
|
||||
SELECT o.*, i.nom AS investisseur_nom
|
||||
FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id
|
||||
WHERE o.investisseur_id = ? AND o.type = ? AND o.annee = ? AND o.plateforme_id IS NULL
|
||||
`).get(investisseurId, data.type, data.annee);
|
||||
res.status(201).json(saved);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -8,7 +8,21 @@ const ALLOWED_KEYS = new Set([
|
||||
'chart_interets',
|
||||
'chart_capital',
|
||||
'chart_cashback',
|
||||
// Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…)
|
||||
'pfo_assujetti',
|
||||
'objectifs_actifs',
|
||||
'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',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -49,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));
|
||||
@@ -59,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;
|
||||
@@ -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;
|
||||
@@ -3,21 +3,47 @@ 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();
|
||||
|
||||
const Schema = z.object({
|
||||
type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme']).default('normal'),
|
||||
type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme', 'interets_plateforme']).default('normal'),
|
||||
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,20 +63,38 @@ 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 };
|
||||
}
|
||||
|
||||
function getTaxIndicatif(investissementId) {
|
||||
if (!investissementId) return false;
|
||||
function getTaxIndicatif(body) {
|
||||
// Intérêts plateforme : pas d'investissement, la fiscalité applicable est celle
|
||||
// de la plateforme associée (bonus_plateforme_id) — pas de fiscalite_override possible.
|
||||
if (body.type === 'interets_plateforme') {
|
||||
if (!body.bonus_plateforme_id) return false;
|
||||
const p = db.prepare('SELECT fiscalite FROM plateformes WHERE id = ?').get(body.bonus_plateforme_id);
|
||||
return p ? p.fiscalite !== 'flat_tax' : false;
|
||||
}
|
||||
if (!body.investissement_id) return false;
|
||||
const row = db.prepare(`
|
||||
SELECT p.fiscalite, i.fiscalite_override
|
||||
FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id
|
||||
WHERE i.id = ?
|
||||
`).get(investissementId);
|
||||
`).get(body.investissement_id);
|
||||
if (!row) return false;
|
||||
return row.fiscalite !== 'flat_tax' || row.fiscalite_override === 'exonere';
|
||||
}
|
||||
@@ -62,7 +106,7 @@ function getTaxIndicatif(investissementId) {
|
||||
* 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);
|
||||
|
||||
@@ -73,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);
|
||||
@@ -132,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 = [];
|
||||
@@ -159,20 +254,25 @@ 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.*,
|
||||
CASE b.type
|
||||
WHEN 'normal' THEN b.inv_nom_projet
|
||||
WHEN 'bonus_parrainage' THEN '— Bonus Parrainage'
|
||||
WHEN 'bonus_plateforme' THEN '— Bonus Plateforme'
|
||||
WHEN 'bonus_parrainage' THEN '— Bonus Parrainage' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
|
||||
WHEN 'bonus_plateforme' THEN '— Bonus Plateforme' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
|
||||
WHEN 'interets_plateforme' THEN '— Intérêts plateforme' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
|
||||
ELSE b.inv_nom_projet
|
||||
END AS nom_projet,
|
||||
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
|
||||
@@ -180,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);
|
||||
});
|
||||
@@ -188,26 +288,35 @@ router.get('/', (req, res) => {
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id));
|
||||
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body));
|
||||
|
||||
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(
|
||||
@@ -229,18 +338,38 @@ router.post('/', (req, res, next) => {
|
||||
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
|
||||
}
|
||||
|
||||
// Bonus (parrainage ou plateforme)
|
||||
// Bonus (parrainage / plateforme) ou Intérêts plateforme
|
||||
const bonusInvestisseurId = body.bonus_investisseur_id ?? req.investisseur.id;
|
||||
assertOwnedInvestisseur(bonusInvestisseurId, req.user.id);
|
||||
|
||||
if (body.type === 'interets_plateforme') {
|
||||
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, 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.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 });
|
||||
}
|
||||
|
||||
const r = db.prepare(`
|
||||
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); }
|
||||
@@ -253,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
|
||||
@@ -277,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=?
|
||||
@@ -303,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,
|
||||
@@ -348,19 +490,20 @@ router.put('/:id', (req, res, next) => {
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
|
||||
const body = Schema.parse(req.body);
|
||||
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id));
|
||||
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,
|
||||
@@ -368,14 +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=?, 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.frais_ttc,
|
||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
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 });
|
||||
@@ -395,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);
|
||||
@@ -414,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);
|
||||
@@ -449,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;
|
||||
}
|
||||
|
||||
@@ -3,7 +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 { buildSchedule, adjustSimulForActuals } from '../utils/schedule.js';
|
||||
import { buildSchedule, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -149,7 +149,14 @@ router.post('/recalculate', (req, res, next) => {
|
||||
const { investissement_id } = req.body;
|
||||
if (!investissement_id) throw new HttpError(400, 'investissement_id required');
|
||||
assertOwnedInvestissement(Number(investissement_id), req.user.id);
|
||||
// adjustSimulForActuals() gère aussi la régénération complète quand aucun capital n'a
|
||||
// encore été remboursé ; replayAdjustSimulForActuals() rejoue ensuite tout l'historique des
|
||||
// remboursements de capital anticipés pour reconstruire fidèlement les valeurs
|
||||
// intermédiaires qu'un simple appel unique ne peut pas restaurer (cf. bug "L'Olympique" du
|
||||
// 22/08/26, feedback_generateSimul_restructuration_dates.md) — sans effet si l'échéancier
|
||||
// était déjà cohérent.
|
||||
adjustSimulForActuals(db, Number(investissement_id));
|
||||
replayAdjustSimulForActuals(db, Number(investissement_id));
|
||||
const rows = db.prepare(
|
||||
'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance'
|
||||
).all(investissement_id);
|
||||
|
||||
@@ -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;
|
||||
@@ -6,6 +6,10 @@ const router = Router();
|
||||
router.use(requireInvestisseur);
|
||||
|
||||
const round2 = v => Math.round((v ?? 0) * 100) / 100;
|
||||
// Échappement CSV (séparateur ';') — remplace les caractères qui casseraient les colonnes.
|
||||
// Bug préexistant : cette fonction était utilisée plus bas dans /export sans être définie
|
||||
// (ReferenceError à l'exécution), corrigé au passage.
|
||||
const esc = v => String(v ?? '').replace(/;/g, ',').replace(/[\r\n]+/g, ' ');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const annee = req.query.annee || String(new Date().getFullYear() - 1);
|
||||
@@ -29,19 +33,47 @@ router.get('/', (req, res) => {
|
||||
AND r.statut IN ('paye','partiel')
|
||||
`).get(invArg, annee);
|
||||
|
||||
// Intérêts plateforme (type=interets_plateforme, pas d'investissement — rattachés via
|
||||
// bonus_plateforme_id/bonus_investisseur_id) : mêmes règles fiscales que la plateforme
|
||||
// associée, donc à additionner aux totaux comme des intérêts d'investissement classiques.
|
||||
const invCondBonus = scopeAll
|
||||
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'r.bonus_investisseur_id = ?';
|
||||
const recapInteretsPlateforme = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(r.interets_bruts),0) AS interets_bruts,
|
||||
COALESCE(SUM(r.prelev_sociaux),0) AS prelev_sociaux,
|
||||
COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire,
|
||||
COALESCE(SUM(r.net_recu),0) AS net_recu,
|
||||
COUNT(*) AS nb_remboursements
|
||||
FROM remboursements r
|
||||
WHERE ${invCondBonus}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
`).get(invArg, annee);
|
||||
|
||||
recap.interets_bruts += recapInteretsPlateforme.interets_bruts;
|
||||
recap.prelev_sociaux += recapInteretsPlateforme.prelev_sociaux;
|
||||
recap.prelev_forfaitaire += recapInteretsPlateforme.prelev_forfaitaire;
|
||||
recap.net_recu += recapInteretsPlateforme.net_recu;
|
||||
recap.nb_remboursements += recapInteretsPlateforme.nb_remboursements;
|
||||
|
||||
// Pertes en capital : uniquement les dossiers explicitement déclarés en perte
|
||||
// définitive (statut='perte_definitive'), avec le montant réellement acté dans
|
||||
// investissement_pertes — pas les simples retards, ni les clôtures normales.
|
||||
const pertes = db.prepare(`
|
||||
SELECT i.id, i.nom_projet, p.nom AS plateforme_nom,
|
||||
i.montant_investi,
|
||||
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) AS capital_rembourse,
|
||||
(i.montant_investi -
|
||||
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0)
|
||||
) AS perte_capital,
|
||||
i.statut, i.updated_at
|
||||
ip.montant_perte AS perte_capital,
|
||||
i.statut, ip.date_effet
|
||||
FROM investissements i
|
||||
JOIN plateformes p ON p.id = i.plateforme_id
|
||||
JOIN investissement_pertes ip ON ip.investissement_id = i.id
|
||||
WHERE ${invCond}
|
||||
AND i.statut IN ('en_retard','cloture')
|
||||
AND substr(i.updated_at,1,4) = ?
|
||||
AND i.statut = 'perte_definitive'
|
||||
AND substr(ip.date_effet,1,4) = ?
|
||||
`).all(invArg, annee);
|
||||
|
||||
const pertesTotales = pertes.reduce((s, p) => s + Math.max(0, p.perte_capital), 0);
|
||||
@@ -85,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),
|
||||
@@ -103,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) => {
|
||||
@@ -125,6 +166,21 @@ router.get('/export', (req, res) => {
|
||||
ORDER BY r.date_remb
|
||||
`).all(invArg, annee);
|
||||
|
||||
const invCondBonusExp = scopeAll
|
||||
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'r.bonus_investisseur_id = ?';
|
||||
const interetsPlateformeRows = db.prepare(`
|
||||
SELECT
|
||||
'— Intérêts plateforme' || CASE WHEN NULLIF(r.notes, '') IS NOT NULL THEN ' — ' || r.notes ELSE '' END AS nom_projet,
|
||||
p.nom AS plateforme,
|
||||
r.date_remb, r.capital, r.interets_bruts,
|
||||
r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu, r.statut
|
||||
FROM remboursements r
|
||||
JOIN plateformes p ON p.id = r.bonus_plateforme_id
|
||||
WHERE ${invCondBonusExp} AND substr(r.date_remb,1,4) = ? AND r.type = 'interets_plateforme'
|
||||
ORDER BY r.date_remb
|
||||
`).all(invArg, annee);
|
||||
|
||||
const corrCond2 = scopeAll
|
||||
? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'c.investisseur_id = ?';
|
||||
@@ -141,7 +197,7 @@ router.get('/export', (req, res) => {
|
||||
`).all(invArg, annee);
|
||||
|
||||
const header = ['Projet','Plateforme','Date','Capital','Interets bruts','Prelev sociaux','Impot revenu','Net recu','Statut'];
|
||||
const allRows = [...rows, ...corrRows];
|
||||
const allRows = [...rows, ...interetsPlateformeRows, ...corrRows].sort((a, b) => (a.date_remb || '').localeCompare(b.date_remb || ''));
|
||||
const csv = [
|
||||
header.join(';'),
|
||||
...allRows.map(r => [
|
||||
@@ -156,15 +212,7 @@ router.get('/export', (req, res) => {
|
||||
res.send('\uFEFF' + csv);
|
||||
});
|
||||
|
||||
/* ── CERFA 2561 — synthèse par plateforme × investisseur ── */
|
||||
router.get('/cerfa2561', (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 invArg = scopeAll ? req.user.id : req.investisseur.id;
|
||||
|
||||
function buildCerfa2561(annee, invCond, invCondBonus2561, invArg) {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
p.id AS plateforme_id,
|
||||
@@ -190,20 +238,62 @@ router.get('/cerfa2561', (req, res) => {
|
||||
ORDER BY p.nom, inv.nom
|
||||
`).all(invArg, annee);
|
||||
|
||||
// Pertes en capital par plateforme × investisseur
|
||||
// Intérêts plateforme (pas d'investissement — rattachés via bonus_plateforme_id/
|
||||
// bonus_investisseur_id) : fusionnés sur la même ligne plateforme × investisseur que
|
||||
// les intérêts d'investissement classiques, car la fiscalité (domiciliation/fiscalite/
|
||||
// type_produit_fiscal) dépend de la plateforme, pas de l'investissement précis.
|
||||
const rowsInteretsPlateforme = db.prepare(`
|
||||
SELECT
|
||||
p.id AS plateforme_id,
|
||||
p.nom AS plateforme_nom,
|
||||
p.domiciliation,
|
||||
p.fiscalite,
|
||||
p.type_produit_fiscal,
|
||||
inv.id AS investisseur_id,
|
||||
inv.nom AS investisseur_nom,
|
||||
inv.prenom AS investisseur_prenom,
|
||||
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
|
||||
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
|
||||
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
|
||||
FROM remboursements r
|
||||
JOIN plateformes p ON p.id = r.bonus_plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
|
||||
WHERE ${invCondBonus2561}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
GROUP BY p.id, inv.id
|
||||
`).all(invArg, annee);
|
||||
|
||||
// Fusion des deux sources sur la clé plateforme × investisseur
|
||||
const rowsMap = {};
|
||||
for (const r of rows) rowsMap[`${r.plateforme_id}_${r.investisseur_id}`] = { ...r };
|
||||
for (const r of rowsInteretsPlateforme) {
|
||||
const key = `${r.plateforme_id}_${r.investisseur_id}`;
|
||||
if (rowsMap[key]) {
|
||||
rowsMap[key].interets_bruts += r.interets_bruts;
|
||||
rowsMap[key].prelev_sociaux += r.prelev_sociaux;
|
||||
rowsMap[key].prelev_forfaitaire += r.prelev_forfaitaire;
|
||||
} else {
|
||||
rowsMap[key] = { ...r };
|
||||
}
|
||||
}
|
||||
const rowsMerged = Object.values(rowsMap).sort((a, b) =>
|
||||
a.plateforme_nom.localeCompare(b.plateforme_nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
|
||||
|
||||
// Pertes en capital par plateforme × investisseur — uniquement les dossiers
|
||||
// explicitement déclarés en perte définitive (voir note plus haut dans la route '/').
|
||||
const pertesRows = db.prepare(`
|
||||
SELECT
|
||||
p.id AS plateforme_id,
|
||||
i.investisseur_id,
|
||||
COALESCE(SUM(
|
||||
i.montant_investi -
|
||||
COALESCE((SELECT SUM(r2.capital) FROM remboursements r2 WHERE r2.investissement_id = i.id),0)
|
||||
), 0) AS perte_capital
|
||||
COALESCE(SUM(ip.montant_perte), 0) AS perte_capital
|
||||
FROM investissements i
|
||||
JOIN plateformes p ON p.id = i.plateforme_id
|
||||
JOIN investissement_pertes ip ON ip.investissement_id = i.id
|
||||
WHERE ${invCond}
|
||||
AND i.statut IN ('en_retard','cloture')
|
||||
AND substr(i.updated_at,1,4) = ?
|
||||
AND i.statut = 'perte_definitive'
|
||||
AND substr(ip.date_effet,1,4) = ?
|
||||
GROUP BY p.id, i.investisseur_id
|
||||
`).all(invArg, annee);
|
||||
|
||||
@@ -212,7 +302,7 @@ router.get('/cerfa2561', (req, res) => {
|
||||
pertesMap[`${p.plateforme_id}_${p.investisseur_id}`] = Math.max(0, p.perte_capital);
|
||||
}
|
||||
|
||||
const lignes = rows.map(r => {
|
||||
const lignes = rowsMerged.map(r => {
|
||||
const key = `${r.plateforme_id}_${r.investisseur_id}`;
|
||||
const perteCapital = pertesMap[key] ?? 0;
|
||||
const isFlatTax = r.domiciliation === 'FR' && r.fiscalite === 'flat_tax';
|
||||
@@ -265,6 +355,23 @@ router.get('/cerfa2561', (req, res) => {
|
||||
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(r.interets_bruts), 0) AS interets_bruts,
|
||||
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
|
||||
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
|
||||
FROM remboursements r
|
||||
JOIN plateformes p ON p.id = r.bonus_plateforme_id
|
||||
WHERE ${invCondBonus2561}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
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}`;
|
||||
@@ -275,13 +382,39 @@ router.get('/cerfa2561', (req, res) => {
|
||||
prelev_forfaitaire: round2(m.prelev_forfaitaire),
|
||||
};
|
||||
}
|
||||
for (const m of moisRowsInteretsPlateforme) {
|
||||
const key = `${m.plateforme_id}_${m.investisseur_id}`;
|
||||
if (!moisMap[key]) moisMap[key] = {};
|
||||
const existing = moisMap[key][m.mois];
|
||||
moisMap[key][m.mois] = {
|
||||
interets_bruts: round2((existing?.interets_bruts ?? 0) + m.interets_bruts),
|
||||
prelev_sociaux: round2((existing?.prelev_sociaux ?? 0) + m.prelev_sociaux),
|
||||
prelev_forfaitaire: round2((existing?.prelev_forfaitaire ?? 0) + m.prelev_forfaitaire),
|
||||
};
|
||||
}
|
||||
|
||||
const lignesWithMois = lignes.map(l => ({
|
||||
...l,
|
||||
mois: moisMap[`${l.plateforme_id}_${l.investisseur_id}`] ?? {},
|
||||
}));
|
||||
|
||||
res.json({ annee, lignes: lignesWithMois });
|
||||
return { annee, lignes: lignesWithMois };
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── CERFA 2561 — synthèse par plateforme × investisseur ── */
|
||||
router.get('/cerfa2561', (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 invCondBonus2561 = 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(buildCerfa2561(annee, invCond, invCondBonus2561, invArg));
|
||||
});
|
||||
|
||||
/* ── CERFA 2561 — détail des remboursements par plateforme × investisseur ── */
|
||||
@@ -318,7 +451,31 @@ router.get('/cerfa2561/remboursements', (req, res) => {
|
||||
ORDER BY r.date_remb
|
||||
`).all(invArg, ...platArgs, annee);
|
||||
|
||||
res.json(rows);
|
||||
// Intérêts plateforme correspondant à la même plateforme (et investisseur si précisé)
|
||||
const invCondBonusDetail = scopeAll
|
||||
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'r.bonus_investisseur_id = ?';
|
||||
const platCondBonus = investisseur_id
|
||||
? 'AND p.id = ? AND r.bonus_investisseur_id = ?'
|
||||
: 'AND p.id = ?';
|
||||
const rowsInteretsPlateforme = db.prepare(`
|
||||
SELECT
|
||||
r.id, r.date_remb, r.capital, r.interets_bruts,
|
||||
r.prelev_sociaux, r.prelev_forfaitaire, r.interets_nets, r.net_recu,
|
||||
r.statut, r.notes,
|
||||
'— Intérêts plateforme' || CASE WHEN NULLIF(r.notes, '') IS NOT NULL THEN ' — ' || r.notes ELSE '' END AS nom_projet
|
||||
FROM remboursements r
|
||||
JOIN plateformes p ON p.id = r.bonus_plateforme_id
|
||||
WHERE ${invCondBonusDetail} ${platCondBonus}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
ORDER BY r.date_remb
|
||||
`).all(invArg, ...platArgs, annee);
|
||||
|
||||
const allRows = [...rows, ...rowsInteretsPlateforme].sort((a, b) => (a.date_remb || '').localeCompare(b.date_remb || ''));
|
||||
|
||||
res.json(allRows);
|
||||
});
|
||||
|
||||
|
||||
@@ -346,21 +503,31 @@ router.get('/years', (req, res) => {
|
||||
ORDER BY annee DESC
|
||||
`).all(...invParams);
|
||||
|
||||
res.json(rows.map(r => r.annee));
|
||||
const invWhereBonus = scopeAll
|
||||
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'r.bonus_investisseur_id = ?';
|
||||
const rowsInteretsPlateforme = db.prepare(`
|
||||
SELECT DISTINCT strftime('%Y', r.date_remb) AS annee
|
||||
FROM remboursements r
|
||||
WHERE ${invWhereBonus}
|
||||
AND r.type = 'interets_plateforme'
|
||||
`).all(...invParams);
|
||||
|
||||
const years = [...new Set([...rows.map(r => r.annee), ...rowsInteretsPlateforme.map(r => r.annee)])]
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
res.json(years);
|
||||
});
|
||||
|
||||
|
||||
|
||||
/* ── 2778-SD — matrice mensuelle par plateforme étrangère ── */
|
||||
router.get('/2778', (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 invArg = scopeAll ? req.user.id : req.investisseur.id;
|
||||
|
||||
// Plateformes étrangères × investisseur avec au moins un remboursement sur l'année
|
||||
// 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 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
|
||||
@@ -372,9 +539,32 @@ router.get('/2778', (req, res) => {
|
||||
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);
|
||||
|
||||
// Intérêts plateforme (pas d'investissement) sur des plateformes étrangères — même
|
||||
// logique de fusion que pour le 2561 : 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 ${invCondBonus2778}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
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 = {};
|
||||
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
|
||||
const moisRows = db.prepare(`
|
||||
SELECT
|
||||
@@ -395,17 +585,45 @@ router.get('/2778', (req, res) => {
|
||||
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);
|
||||
|
||||
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 ${invCondBonus2778}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
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);
|
||||
|
||||
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 = platRows.map(p => ({
|
||||
const plateformes = platRowsMerged.map(p => ({
|
||||
id: `${p.id}_${p.investisseur_id}`,
|
||||
plateforme_id: p.id,
|
||||
nom: p.nom,
|
||||
@@ -415,7 +633,355 @@ router.get('/2778', (req, res) => {
|
||||
mois: moisMap[`${p.id}_${p.investisseur_id}`] ?? {},
|
||||
}));
|
||||
|
||||
res.json({ annee, plateformes });
|
||||
return { annee, plateformes };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* ── 2778-SD — matrice mensuelle par plateforme étrangère ── */
|
||||
router.get('/2778', (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 invCondBonus2778 = 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(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
|
||||
p.id AS plateforme_id, p.nom AS plateforme_nom, p.domiciliation,
|
||||
p.fiscalite, p.taux_fiscalite_locale,
|
||||
inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
|
||||
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_avant_local,
|
||||
COALESCE(SUM(r.interets_bruts), 0) AS montant_net,
|
||||
COALESCE(SUM(r.taxe_locale), 0) AS impot_local
|
||||
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.domiciliation != 'FR'
|
||||
AND p.categorie_plateforme = 'psfp'
|
||||
GROUP BY p.id, inv.id
|
||||
`).all(invArg, annee);
|
||||
|
||||
const rowsInteretsPlateforme = db.prepare(`
|
||||
SELECT
|
||||
p.id AS plateforme_id, p.nom AS plateforme_nom, p.domiciliation,
|
||||
p.fiscalite, p.taux_fiscalite_locale,
|
||||
inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
|
||||
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_avant_local,
|
||||
COALESCE(SUM(r.interets_bruts), 0) AS montant_net,
|
||||
COALESCE(SUM(r.taxe_locale), 0) AS impot_local
|
||||
FROM remboursements r
|
||||
JOIN plateformes p ON p.id = r.bonus_plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
|
||||
WHERE ${invCondBonus2047}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
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);
|
||||
|
||||
const rowsMap2047 = {};
|
||||
for (const r0 of rows) rowsMap2047[`${r0.plateforme_id}_${r0.investisseur_id}`] = { ...r0 };
|
||||
for (const r0 of rowsInteretsPlateforme) {
|
||||
const key = `${r0.plateforme_id}_${r0.investisseur_id}`;
|
||||
if (rowsMap2047[key]) {
|
||||
rowsMap2047[key].montant_avant_local += r0.montant_avant_local;
|
||||
rowsMap2047[key].montant_net += r0.montant_net;
|
||||
rowsMap2047[key].impot_local += r0.impot_local;
|
||||
} else {
|
||||
rowsMap2047[key] = { ...r0 };
|
||||
}
|
||||
}
|
||||
const rowsMerged2047 = Object.values(rowsMap2047).sort((a, b) =>
|
||||
a.plateforme_nom.localeCompare(b.plateforme_nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
|
||||
|
||||
// Référentiel pays (taux crédit d'impôt) — jointure par code pays (= plateformes.domiciliation)
|
||||
const paysRows = db.prepare('SELECT * FROM taux_credit_impot').all();
|
||||
const paysMap = {};
|
||||
for (const p of paysRows) {
|
||||
if (p.code_pays) paysMap[p.code_pays.trim().toUpperCase()] = p;
|
||||
}
|
||||
|
||||
const lignes = rowsMerged2047.map(r0 => {
|
||||
const pays = paysMap[(r0.domiciliation || '').trim().toUpperCase()] || null;
|
||||
// Règle fiscale : les montants portés sur les cases d'une déclaration sont arrondis à
|
||||
// l'euro le plus proche (même convention que les cases BA/IA/QR du 2778-SD — cf. computeCases
|
||||
// côté frontend). On arrondit donc ici la base (net encaissé / impôt local) avant d'en dériver
|
||||
// les cases 235/237/238, pour rester cohérent avec le mode de calcul du 2778-SD.
|
||||
const montantNet = Math.round(r0.montant_net);
|
||||
const impotLocal = Math.round(r0.impot_local);
|
||||
|
||||
// Éligible aux cases 231-238 seulement si DEUX conditions sont réunies :
|
||||
// 1) le pays ouvre droit à un crédit d'impôt (convention active, non "exclusif résidence" —
|
||||
// pictogramme c/ = imposable exclusivement en France, donc aucun crédit possible) ;
|
||||
// 2) la plateforme a réellement prélevé un impôt à la source sur l'année (impotLocal > 0).
|
||||
// Sans retenue réelle, il n'y a rien à créditer : la ligne va en case 250 avec son montant
|
||||
// net intégral (= montant brut, puisqu'aucun impôt étranger n'a été prélevé dessus), même
|
||||
// si le pays serait par ailleurs éligible (ex. plateforme "sans fiscalité locale").
|
||||
const paysEligible = !!pays
|
||||
&& pays.statut_convention === 'active'
|
||||
&& !pays.int_exclusif_residence;
|
||||
const eligible = paysEligible && impotLocal > 0;
|
||||
// Certains pays (ex. Bénin, Saint-Barthélemy) n'ont pas de taux forfaitaire chiffré dans la
|
||||
// notice ("le crédit d'impôt est égal à l'impôt prélevé", sans plafond en %) : le crédit est
|
||||
// alors non plafonné et égal à l'impôt réellement supporté à l'étranger.
|
||||
const tauxPlafonne = eligible && pays.int_taux != null;
|
||||
|
||||
const tauxApplicable = tauxPlafonne ? pays.int_taux : null;
|
||||
const resultat = tauxPlafonne ? Math.round(montantNet * pays.int_taux / 100) : (eligible ? impotLocal : 0);
|
||||
const creditRetenu = eligible ? Math.round(Math.min(resultat, impotLocal)) : 0;
|
||||
const interetsInclus = eligible ? montantNet + creditRetenu : 0;
|
||||
|
||||
let motif = null;
|
||||
if (!eligible) {
|
||||
if (!pays) motif = "Aucune convention fiscale identifiée pour ce pays";
|
||||
else if (pays.statut_convention !== 'active') motif = `Convention fiscale ${pays.statut_convention} avec ce pays`;
|
||||
else if (pays.int_exclusif_residence) motif = "Revenus imposables exclusivement en France (pas de crédit d'impôt)";
|
||||
else motif = "Aucune retenue à la source appliquée par cette plateforme (rien à créditer)";
|
||||
}
|
||||
|
||||
return {
|
||||
plateforme_id: r0.plateforme_id,
|
||||
plateforme_nom: r0.plateforme_nom,
|
||||
domiciliation: r0.domiciliation,
|
||||
investisseur_id: r0.investisseur_id,
|
||||
investisseur_nom: r0.investisseur_nom,
|
||||
investisseur_prenom: r0.investisseur_prenom,
|
||||
pays_nom: pays?.nom_pays ?? null,
|
||||
statut_convention: pays?.statut_convention ?? null,
|
||||
eligible_ci: eligible,
|
||||
taux_plafonne: tauxPlafonne,
|
||||
motif,
|
||||
montant_avant_local: round2(r0.montant_avant_local),
|
||||
case_233: eligible ? montantNet : 0,
|
||||
case_234: tauxApplicable,
|
||||
case_235: resultat,
|
||||
case_236: eligible ? impotLocal : 0,
|
||||
case_237: creditRetenu,
|
||||
case_238: interetsInclus,
|
||||
case_250: eligible ? 0 : montantNet,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = lignes.reduce((acc, l) => ({
|
||||
montant_avant_local: acc.montant_avant_local + l.montant_avant_local,
|
||||
case_233: acc.case_233 + l.case_233,
|
||||
case_236: acc.case_236 + l.case_236,
|
||||
case_237: acc.case_237 + l.case_237,
|
||||
case_238: acc.case_238 + l.case_238,
|
||||
case_250: acc.case_250 + l.case_250,
|
||||
}), { montant_avant_local: 0, case_233: 0, case_236: 0, case_237: 0, case_238: 0, case_250: 0 });
|
||||
|
||||
totals.case_251 = totals.case_238 + totals.case_250; // Total des intérêts imposables (238+240+250, 240 non utilisé ici)
|
||||
totals.case_252 = totals.case_251; // dont intérêts et produits de placement à revenu fixe → 2042 case 2TR
|
||||
totals.case_8VL = totals.case_237; // Crédit d'impôt conventionnel total (cadre 7) → 2042C case 8VL
|
||||
totals.case_8PL = totals.case_233; // Montant net ouvrant droit à crédit d'impôt étranger → 2042C case 8PL
|
||||
// case_233/236/237/238/250/251/252/8VL/8PL sont déjà des entiers (euros) — seul montant_avant_local
|
||||
// (info complémentaire, non reportée sur une case du formulaire) reste arrondi au centime.
|
||||
totals.montant_avant_local = round2(totals.montant_avant_local);
|
||||
|
||||
return { annee, lignes, totals };
|
||||
}
|
||||
|
||||
|
||||
/* ── 2047 — Cadre 2, rubrique 230 (Intérêts) — vision annuelle par pays/plateforme ──
|
||||
Détermine, pour chaque plateforme étrangère × investisseur, si les intérêts perçus
|
||||
ouvrent droit à un crédit d'impôt conventionnel (cases 231-238) ou non (case 250),
|
||||
en s'appuyant sur le référentiel pays "taux_credit_impot" (paramétrage admin).
|
||||
É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';
|
||||
const invCond = scopeAll
|
||||
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'i.investisseur_id = ?';
|
||||
const invCondBonus2047 = 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(build2047(annee, invCond, invCondBonus2047, invArg));
|
||||
});
|
||||
|
||||
export { buildCerfa2561, build2778, build2047, build2042CPro };
|
||||
export default router;
|
||||
@@ -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;
|
||||
@@ -3,6 +3,8 @@ import db from '../../db/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const round2 = v => Math.round((v ?? 0) * 100) / 100;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard:
|
||||
@@ -13,7 +15,23 @@ const router = Router();
|
||||
* aujourd'hui), pas des cumuls par période — un investissement remboursé
|
||||
* partiellement reste compté pour son capital restant dû tant qu'il
|
||||
* n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés
|
||||
* par année via `?annee=`.
|
||||
* 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 − 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:
|
||||
@@ -84,7 +102,114 @@ router.get('/', (req, res) => {
|
||||
FROM depots_retraits WHERE ${invCond('investisseur_id')}
|
||||
`).get(invParam);
|
||||
|
||||
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
|
||||
// ── Gain net depuis le début — même formule que le KPI du Dashboard interne :
|
||||
// Σ(intérêts nets, tout l'historique) + Σ(cashback + bonus parrainage/plateforme,
|
||||
// tout l'historique) + Σ(corrections de solde, tout l'historique). Volontairement
|
||||
// TOUJOURS un cumul total, jamais filtré par `annee` (cf. description ci-dessus) —
|
||||
// c'est ce qui le rend robuste à un retrait complet des plateformes, contrairement
|
||||
// à une approche par solde. La clause OR ci-dessous inclut aussi bien les
|
||||
// remboursements liés à un investissement que les "intérêts plateforme"/bonus de
|
||||
// parrainage rattachés via bonus_investisseur_id (sans investissement), en miroir
|
||||
// exact de GET /api/dashboard/interets-annuels.
|
||||
const gainNetCond = req.investisseurScopeAll
|
||||
? '(r.investissement_id IS NOT NULL AND own.user_id = ?) OR (r.bonus_investisseur_id IS NOT NULL AND bonus_own.user_id = ?)'
|
||||
: '(r.investissement_id IS NOT NULL AND inv.investisseur_id = ?) OR (r.bonus_investisseur_id IS NOT NULL AND r.bonus_investisseur_id = ?)';
|
||||
|
||||
const gainNetRow = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets_total,
|
||||
COALESCE(SUM(r.cashback), 0) AS cashback_total
|
||||
FROM remboursements r
|
||||
LEFT JOIN investissements inv ON inv.id = r.investissement_id
|
||||
LEFT JOIN investisseurs own ON own.id = inv.investisseur_id
|
||||
LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id
|
||||
WHERE ${gainNetCond}
|
||||
`).get(invParam, invParam);
|
||||
|
||||
const correctionsRow = db.prepare(`
|
||||
SELECT COALESCE(SUM(montant), 0) AS total
|
||||
FROM corrections_solde
|
||||
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 },
|
||||
cash,
|
||||
gain_net_depuis_debut: {
|
||||
total: gainNetTotal,
|
||||
interets_nets: round2(gainNetRow.interets_nets_total),
|
||||
cashback: round2(gainNetRow.cashback_total),
|
||||
corrections: round2(correctionsRow.total),
|
||||
frais_hors_remboursement: round2(fraisHorsRembRow.total),
|
||||
},
|
||||
investissements_pe,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,16 +1,24 @@
|
||||
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';
|
||||
|
||||
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);
|
||||
|
||||
export default router;
|
||||
@@ -21,7 +21,7 @@ const LIST_COLUMNS = `
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: statut
|
||||
* schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture] }
|
||||
* schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture, perte_definitive, prolongation] }
|
||||
* responses:
|
||||
* 200: { description: Liste des investissements }
|
||||
*/
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../db/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /objectifs:
|
||||
* get:
|
||||
* summary: Objectifs de versement et de capital investi
|
||||
* description: >
|
||||
* Objectifs fixés dans Paramétrage → Fixation des objectifs, à comparer au réalisé
|
||||
* (voir crowdlending_get_dashboard / crowdlending_list_depots_retraits pour le
|
||||
* réalisé). Trois types : `versement_global` (objectif global sur l'investisseur
|
||||
* principal), `versement_annuel` (enveloppe par détenteur), `capital_investi`
|
||||
* (objectif de capital investi par plateforme, `plateforme_id` renseigné). La liste
|
||||
* est vide si la fonctionnalité est désactivée par l'utilisateur, ou si aucun
|
||||
* objectif n'a encore été saisi.
|
||||
* tags: [Objectifs]
|
||||
* security: [{ ApiKeyAuth: [] }]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: type
|
||||
* schema: { type: string, enum: [versement_global, versement_annuel, capital_investi] }
|
||||
* description: Filtrer par type d'objectif.
|
||||
* - in: query
|
||||
* name: annee
|
||||
* schema: { type: integer }
|
||||
* description: Filtrer par année.
|
||||
* responses:
|
||||
* 200: { description: Liste des objectifs }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const { type, annee } = req.query;
|
||||
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du foyer ;
|
||||
// clé mono-investisseur → uniquement les objectifs de cet investisseur (contrairement à
|
||||
// la route interne /api/objectifs, qui renvoie tout le foyer car elle est scopée par
|
||||
// session JWT — une clé API mono-investisseur ne doit voir que ses propres objectifs).
|
||||
const conds = [req.investisseurScopeAll
|
||||
? 'o.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'o.investisseur_id = ?'];
|
||||
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
|
||||
if (type) { conds.push('o.type = ?'); args.push(type); }
|
||||
if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); }
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT o.*, i.nom AS investisseur_nom, p.nom AS plateforme_nom
|
||||
FROM objectifs o
|
||||
JOIN investisseurs i ON i.id = o.investisseur_id
|
||||
LEFT JOIN plateformes p ON p.id = o.plateforme_id
|
||||
WHERE ${conds.join(' AND ')}
|
||||
ORDER BY o.annee DESC, i.nom
|
||||
`).all(...args);
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../db/index.js';
|
||||
import { buildCerfa2561, build2778, build2047, build2042CPro } from '../taxreport.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const sumBy = (arr, key) => arr.reduce((s, x) => s + (x[key] ?? 0), 0);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /taxreport:
|
||||
* get:
|
||||
* summary: Synthèse fiscale annuelle (CERFA 2042 / 2561 / 2047 / 2778-SD)
|
||||
* description: >
|
||||
* Aide à la préparation de la déclaration de revenus, calculée avec exactement les
|
||||
* mêmes règles que la page Fiscalité de l'application (mêmes fonctions backend que
|
||||
* les routes internes /taxreport/cerfa2561, /taxreport/2778 et /taxreport/2047).
|
||||
* Ce n'est PAS une source officielle : vérifiez chaque montant avant de le reporter
|
||||
* sur votre déclaration, vous restez seul responsable de son exactitude.
|
||||
*
|
||||
* `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 "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 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
|
||||
* (réglage propre au navigateur, non accessible depuis l'API) — en cas d'exclusion,
|
||||
* la valeur affichée dans l'app peut différer légèrement de celle-ci.
|
||||
* tags: [Fiscalité]
|
||||
* security: [{ ApiKeyAuth: [] }]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: annee
|
||||
* schema: { type: integer }
|
||||
* description: Année fiscale (ex. 2026). Par défaut, l'année précédente.
|
||||
* responses:
|
||||
* 200: { description: Synthèse fiscale annuelle }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const annee = req.query.annee || String(new Date().getFullYear() - 1);
|
||||
const scopeAll = req.investisseurScopeAll;
|
||||
const invParam = scopeAll ? req.userId : req.investisseurId;
|
||||
|
||||
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du foyer ;
|
||||
// clé mono-investisseur → filtre sur req.investisseurId. Mêmes fragments SQL que ceux
|
||||
// construits par les routes internes /taxreport/* (cf. backend/src/routes/taxreport.js),
|
||||
// passés en paramètre aux fonctions partagées buildCerfa2561/build2778/build2047 pour
|
||||
// garantir des résultats identiques à ceux affichés dans l'application.
|
||||
const invCond = scopeAll
|
||||
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'i.investisseur_id = ?';
|
||||
const invCondBonus = scopeAll
|
||||
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'r.bonus_investisseur_id = ?';
|
||||
|
||||
// pfo_assujetti est une préférence GLOBALE de l'utilisateur (table user_preferences,
|
||||
// clé user_id) — même mécanisme que UiContext.jsx côté frontend, pas une donnée liée à
|
||||
// un investisseur en particulier.
|
||||
const prefRow = db.prepare(
|
||||
`SELECT value FROM user_preferences WHERE user_id = ? AND key = 'pfo_assujetti'`
|
||||
).get(req.userId);
|
||||
const pfoAssujetti = prefRow ? prefRow.value === 'true' : false;
|
||||
|
||||
// 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' 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);
|
||||
|
||||
let cerfa2047 = null;
|
||||
let cerfa2778_mensuel = null;
|
||||
if (hasEtranger) {
|
||||
cerfa2047 = build2047(annee, invCond, invCondBonus, invParam);
|
||||
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,
|
||||
// statut 'perte_definitive' uniquement) : cumul sur toutes les lignes 2561,
|
||||
// FR et étranger confondues — une perte définitive n'est pas spécifique à la France.
|
||||
// - 2TR : cumul des lignes 2561 françaises + brut étranger (toujours reporté, que le
|
||||
// PFO s'applique ou non — seule la déclaration annuelle 2047 est en jeu ici).
|
||||
// - 2BH / 2CK : la part étrangère n'est ajoutée que si pfo_assujetti est vrai, sinon
|
||||
// ces cases ne sont pas censées être alimentées (cf. mémoire projet — la case 2CK
|
||||
// ne s'alimente que si la déclaration 2778-SD est active).
|
||||
const frLignes = cerfa2561.lignes.filter(l => l.domiciliation === 'FR');
|
||||
// case_2TY (pertes en capital) n'est pas spécifique FR/étranger — voir TaxReport.jsx
|
||||
const allLignes = cerfa2561.lignes;
|
||||
const platEtr = cerfa2778_mensuel?.plateformes ?? [];
|
||||
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
|
||||
const totalBA = platEtr.reduce((s, p) => s + etrBA(p), 0);
|
||||
const PFO_RATE = 0.128; // même valeur par défaut que celle utilisée par TaxReport.jsx pour cette synthèse
|
||||
|
||||
const synthese_2042 = {
|
||||
case_2TT: sumBy(frLignes, 'case_2TT'),
|
||||
case_2TR: sumBy(frLignes, 'case_2TR') + Math.round(totalBA),
|
||||
case_2BH: sumBy(frLignes, 'case_2BH') + (pfoAssujetti ? Math.round(totalBA) : 0),
|
||||
case_2CK: sumBy(frLignes, 'case_2CK') + (pfoAssujetti ? Math.round(totalBA * PFO_RATE) : 0),
|
||||
case_2TY: sumBy(allLignes, 'case_2TY'),
|
||||
};
|
||||
|
||||
res.json({
|
||||
annee,
|
||||
has_etranger: hasEtranger,
|
||||
has_psan: hasPsan,
|
||||
pfo_assujetti: pfoAssujetti,
|
||||
synthese_2042,
|
||||
cerfa2561,
|
||||
cerfa2047,
|
||||
cerfa2778_mensuel,
|
||||
cerfa2042cpro,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -95,15 +120,20 @@ app.get('/api/app-info', (_, res) => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -125,7 +125,8 @@ export function monthsDiff(isoA, isoB) {
|
||||
export function adjustSimulForActuals(db, investissementId) {
|
||||
const inv = db.prepare(`
|
||||
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois,
|
||||
date_cible
|
||||
FROM investissements WHERE id = ?
|
||||
`).get(investissementId);
|
||||
|
||||
@@ -158,7 +159,78 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0'
|
||||
).get(investissementId);
|
||||
|
||||
// Capital effectivement investi jusqu'à la date du dernier remboursement
|
||||
applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital);
|
||||
|
||||
// Ajustement de la première période partielle (mois incomplet)
|
||||
adjustFirstPartialPeriod(db, investissementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoue chronologiquement l'intégralité de l'historique des remboursements de capital
|
||||
* anticipés pour reconstruire l'ajustement progressif de l'échéancier.
|
||||
*
|
||||
* Nécessaire après une régénération COMPLÈTE (generateSimul()/generateSimulWithReinvestissements()
|
||||
* en mode standard, qui réécrit TOUT l'échéancier à neuf à partir du montant_investi d'origine,
|
||||
* sans connaître les remboursements anticipés déjà effectués) — contrairement à un simple appel
|
||||
* à adjustSimulForActuals(), qui ne recalcule que les échéances postérieures au DERNIER
|
||||
* remboursement connu et ne peut donc pas reconstituer une valeur intermédiaire déjà "gelée"
|
||||
* par un remboursement plus récent (ex. la dernière échéance d'un prêt in fine, soldée
|
||||
* partiellement : son capital_prevu ajusté a été fixé par le remboursement anticipé PRÉCÉDENT,
|
||||
* pas par celui qui la solde, donc un unique appel après régénération ne la retoucherait
|
||||
* jamais — il faut rejouer les remboursements dans l'ordre pour reconstruire cette valeur).
|
||||
*
|
||||
* Utilisé par regenererEcheancier() (routes/investissements.js) après une régénération
|
||||
* complète (ex. annulation d'une révision) — jamais après une restructuration partielle, où
|
||||
* les échéances déjà honorées sont préservées telles quelles par generateSimul() lui-même.
|
||||
*
|
||||
* Bug découvert le 22/08/26 sur le dossier "L'Olympique" : l'annulation d'une révision a
|
||||
* effacé tout l'historique d'ajustement de capital anticipé accumulé sur 18 échéances,
|
||||
* l'échéancier retombant sur les valeurs naïves d'origine (intérêts et capital final
|
||||
* recalculés sur le montant investi total, comme si aucun remboursement anticipé n'avait
|
||||
* jamais eu lieu).
|
||||
*/
|
||||
export function replayAdjustSimulForActuals(db, investissementId) {
|
||||
const inv = db.prepare(`
|
||||
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
|
||||
FROM investissements WHERE id = ?
|
||||
`).get(investissementId);
|
||||
|
||||
if (!inv || inv.taux_interet == null || !inv.duree_mois) return;
|
||||
|
||||
const reinvests = db.prepare(
|
||||
'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
|
||||
).all(investissementId);
|
||||
|
||||
// Chaque date distincte où du capital a été remboursé, dans l'ordre chronologique — ce sont
|
||||
// les points où adjustSimulForActuals() aurait été appelé en conditions réelles.
|
||||
const dates = db.prepare(`
|
||||
SELECT DISTINCT date_remb FROM remboursements
|
||||
WHERE investissement_id = ? AND capital > 0
|
||||
ORDER BY date_remb ASC
|
||||
`).all(investissementId).map(r => r.date_remb);
|
||||
|
||||
if (!dates.length) return; // aucun remboursement de capital : l'échéancier naïf est déjà correct
|
||||
|
||||
for (const d of dates) {
|
||||
const { total_capital } = db.prepare(
|
||||
'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ? AND date_remb <= ?'
|
||||
).get(investissementId, d);
|
||||
applyCapitalAdjustment(db, inv, reinvests, d, total_capital);
|
||||
}
|
||||
|
||||
adjustFirstPartialPeriod(db, investissementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cœur du recalcul des échéances futures pour un capital remboursé et une date de référence
|
||||
* donnés — factorisé entre adjustSimulForActuals() (un seul point dans le temps : le dernier
|
||||
* remboursement connu) et replayAdjustSimulForActuals() (rejoue ce même calcul à CHAQUE point
|
||||
* historique, dans l'ordre, pour reconstruire les valeurs intermédiaires "gelées" par des
|
||||
* remboursements plus récents).
|
||||
*/
|
||||
function applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital) {
|
||||
// Capital effectivement investi jusqu'à la date de référence
|
||||
// (initial + réinvestissements survenus avant ou à cette date)
|
||||
const capitalAtLastDate = round2(
|
||||
inv.montant_investi +
|
||||
@@ -167,25 +239,25 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
|
||||
const remainingCapital = round2(capitalAtLastDate - total_capital);
|
||||
|
||||
// Entrées de simulation à recalculer (strictement après la date du dernier remb capital)
|
||||
// Entrées de simulation à recalculer (strictement après la date de référence)
|
||||
const futureEntries = db.prepare(`
|
||||
SELECT * FROM simul_remboursements
|
||||
WHERE investissement_id = ? AND date_prevue > ?
|
||||
ORDER BY numero_echeance
|
||||
`).all(investissementId, last_date);
|
||||
`).all(inv.id, last_date);
|
||||
|
||||
if (futureEntries.length === 0) return;
|
||||
|
||||
// Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante
|
||||
if (remainingCapital <= 0) {
|
||||
// Entrée simul qui couvre la période du dernier remboursement capital
|
||||
// Entrée simul qui couvre la période de la date de référence
|
||||
const lastPaidEntry = db.prepare(`
|
||||
SELECT id, interets_prevus, capital_prevu
|
||||
FROM simul_remboursements
|
||||
WHERE investissement_id = ? AND date_prevue <= ?
|
||||
ORDER BY date_prevue DESC
|
||||
LIMIT 1
|
||||
`).get(investissementId, last_date);
|
||||
`).get(inv.id, last_date);
|
||||
|
||||
db.transaction(() => {
|
||||
// Supprimer les échéances futures devenues caduques
|
||||
@@ -213,7 +285,7 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
const nFuture = futureEntries.length;
|
||||
const type = inv.type_remb || 'in_fine';
|
||||
|
||||
// Réinvestissements encore à venir (après la date du dernier remboursement)
|
||||
// Réinvestissements encore à venir (après la date de référence)
|
||||
const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date);
|
||||
|
||||
const updates = [];
|
||||
@@ -278,9 +350,6 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
`);
|
||||
for (const u of updates) stmt.run(u.capital, u.interets, u.total, u.id);
|
||||
})();
|
||||
|
||||
// Ajustement de la première période partielle (mois incomplet)
|
||||
adjustFirstPartialPeriod(db, investissementId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,7 +434,8 @@ function adjustFirstPartialPeriod(db, investissementId) {
|
||||
export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
const inv = db.prepare(`
|
||||
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois,
|
||||
date_cible
|
||||
FROM investissements WHERE id = ?
|
||||
`).get(investissementId);
|
||||
|
||||
@@ -384,20 +454,74 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
|
||||
if (inv.taux_interet == null || !inv.duree_mois) return;
|
||||
|
||||
const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription;
|
||||
// Cas particulier "differe" (versement unique), avec réinvestissements : une seule échéance
|
||||
// au total, ancrée sur date_cible (la date d'effet de la révision la plus récente, jamais
|
||||
// recalculée à partir d'un décompte de mois écoulés — cf. commentaire équivalent, et le
|
||||
// bug qu'il corrige, dans generateSimul() plus bas).
|
||||
if ((inv.type_remb || 'in_fine') === 'differe') {
|
||||
const finDeMoisDiffere = !!inv.echeance_fin_de_mois;
|
||||
const startDate = inv.date_cible || inv.date_premiere_echeance || inv.date_souscription;
|
||||
if (!startDate) return;
|
||||
const date = finDeMoisDiffere ? lastDayOfMonth(startDate) : startDate;
|
||||
const capital = round2(
|
||||
inv.montant_investi +
|
||||
reinvests.filter(r => r.date_reinvestissement <= date).reduce((s, r) => s + r.montant, 0)
|
||||
);
|
||||
const interets = round2(capital * (inv.taux_interet / 100 / 12) * inv.duree_mois);
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
|
||||
db.prepare(`
|
||||
INSERT INTO simul_remboursements
|
||||
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`).run(investissementId, 1, date, capital, interets, round2(capital + interets));
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const finDeMois = !!inv.echeance_fin_de_mois;
|
||||
const type = inv.type_remb || 'in_fine';
|
||||
const freq = inv.freq_interets || 'mensuel';
|
||||
const step = freq === 'trimestriel' ? 3 : 1;
|
||||
const rPer = (inv.taux_interet / 100 / 12) * step;
|
||||
const isRestructuration = !!inv.date_debut_simul;
|
||||
|
||||
// Durée effective (tient compte d'une éventuelle restructuration)
|
||||
let effectiveDuree = inv.duree_mois;
|
||||
if (inv.date_debut_simul && inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) {
|
||||
const elapsed = monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul);
|
||||
effectiveDuree = Math.max(1, inv.duree_mois - elapsed);
|
||||
// En mode restructuration, il faut connaître les échéances déjà honorées AVANT de
|
||||
// calculer la date de départ de la suite de l'échéancier (cf. generateSimul() pour le
|
||||
// détail du raisonnement) — simple lecture, peut se faire hors transaction.
|
||||
let keptEntries = [];
|
||||
if (isRestructuration) {
|
||||
keptEntries = db.prepare(`
|
||||
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
|
||||
WHERE sr.investissement_id = ?
|
||||
AND sr.date_prevue < ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM remboursements r
|
||||
WHERE r.investissement_id = sr.investissement_id
|
||||
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
|
||||
)
|
||||
ORDER BY sr.numero_echeance
|
||||
`).all(investissementId, inv.date_debut_simul);
|
||||
}
|
||||
|
||||
let startDate, effectiveDuree = inv.duree_mois, elapsedMonths = 0;
|
||||
|
||||
if (isRestructuration) {
|
||||
// Même logique que generateSimul() : numérotation absolue anti-collision (jamais en
|
||||
// dessous du plus haut numero_echeance déjà conservé) et date de départ qui continue
|
||||
// la cadence mensuelle d'origine plutôt que de repartir littéralement de date_debut_simul.
|
||||
const dateBasedElapsed = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance)
|
||||
? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul)
|
||||
: keptEntries.length;
|
||||
const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0);
|
||||
elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero);
|
||||
effectiveDuree = Math.max(1, inv.duree_mois - elapsedMonths);
|
||||
|
||||
const cadenceBase = inv.date_premiere_echeance || inv.date_debut_simul;
|
||||
startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths);
|
||||
} else {
|
||||
startDate = inv.date_premiere_echeance || inv.date_souscription;
|
||||
if (!startDate) return;
|
||||
}
|
||||
|
||||
// Calendrier de base pour obtenir les dates de chaque échéance
|
||||
@@ -463,24 +587,10 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
if (inv.date_debut_simul) {
|
||||
if (isRestructuration) {
|
||||
// ── Mode restructuration ──────────────────────────────────────────────
|
||||
// Même logique que generateSimul() : conserver les échéances déjà payées avant
|
||||
// la date de restructuration, supprimer le reste, et renuméroter à partir du
|
||||
// nombre de mois réellement écoulés (pas de repartir à 1, qui ferait disparaître
|
||||
// les échéances passées — déjà payées — de la table des projections).
|
||||
const keptEntries = db.prepare(`
|
||||
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
|
||||
WHERE sr.investissement_id = ?
|
||||
AND sr.date_prevue < ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM remboursements r
|
||||
WHERE r.investissement_id = sr.investissement_id
|
||||
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
|
||||
)
|
||||
ORDER BY sr.numero_echeance
|
||||
`).all(investissementId, inv.date_debut_simul);
|
||||
|
||||
// keptEntries/elapsedMonths déjà calculés plus haut (nécessaire pour la date de
|
||||
// départ du calendrier de base) : on les réutilise tels quels ici.
|
||||
if (keptEntries.length > 0) {
|
||||
db.prepare(
|
||||
`DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})`
|
||||
@@ -489,10 +599,6 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
|
||||
}
|
||||
|
||||
const elapsedMonths = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance)
|
||||
? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul)
|
||||
: keptEntries.length;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO simul_remboursements
|
||||
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
|
||||
@@ -520,47 +626,66 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
* Génère (ou régénère) le tableau d'amortissement d'un investissement dans la DB.
|
||||
* Ne fait rien si taux_interet ou duree_mois est absent.
|
||||
*
|
||||
* Si date_debut_simul est renseigné (restructuration de prêt), la simulation
|
||||
* démarre à cette date et la durée effective est réduite du nombre de mois déjà
|
||||
* écoulés depuis date_premiere_echeance, afin de ne pas allonger artificiellement le prêt.
|
||||
* Si date_debut_simul est renseigné (restructuration de prêt suite à une révision des
|
||||
* conditions), la durée effective est réduite du nombre de mois déjà écoulés depuis
|
||||
* date_premiere_echeance, afin de ne pas allonger artificiellement le prêt — et la suite
|
||||
* de l'échéancier continue la cadence mensuelle d'origine (même jour du mois que
|
||||
* date_premiere_echeance) à partir de la première échéance pas encore écoulée. date_debut_simul
|
||||
* n'est PAS utilisé comme date de la première nouvelle échéance : ce n'est que la date
|
||||
* d'effet administrative de la révision, qui peut tomber n'importe quel jour du mois.
|
||||
*/
|
||||
export function generateSimul(db, inv) {
|
||||
const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois } = inv;
|
||||
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois,
|
||||
date_cible } = inv;
|
||||
|
||||
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
|
||||
if (taux_interet == null || !duree_mois) return;
|
||||
|
||||
// date_debut_simul remplace le point de départ quand le prêt a été restructuré
|
||||
const startDate = date_debut_simul || date_premiere_echeance || date_souscription;
|
||||
if (!startDate) return;
|
||||
const finDeMois = !!echeance_fin_de_mois;
|
||||
|
||||
// Durée effective : si restructuration, on soustrait les mois déjà écoulés
|
||||
// pour que la simulation se termine bien à la date cible contractuelle d'origine.
|
||||
let effectiveDuree = duree_mois;
|
||||
if (date_debut_simul && date_premiere_echeance && date_debut_simul > date_premiere_echeance) {
|
||||
const elapsed = monthsDiff(date_premiere_echeance, date_debut_simul);
|
||||
effectiveDuree = Math.max(1, duree_mois - elapsed);
|
||||
// Cas particulier "differe" (versement unique) : il n'existe jamais qu'une seule échéance
|
||||
// au total, donc le mécanisme de restructuration ci-dessous (numérotation par mois écoulés
|
||||
// depuis date_premiere_echeance, conservation d'échéances déjà honorées) n'a pas de sens —
|
||||
// il a été conçu pour les prêts à échéances multiples (in_fine/amortissable) où l'on
|
||||
// reprend le calendrier en cours de route. Appliqué tel quel à un prêt différé après une
|
||||
// révision (ex. prolongation), il produisait un numero_echeance et une date aberrants :
|
||||
// numero_echeance = mois écoulés + 1 (ex. n°10) au lieu de 1, et une date recalculée à
|
||||
// partir de l'ancienne cadence (date_premiere_echeance + mois écoulés) au lieu de la
|
||||
// nouvelle date cible — bug signalé par Olivier le 22/08/26 sur le dossier "Le Dolce
|
||||
// Notte". On régénère donc toujours une unique échéance, ancrée sur date_cible (la date
|
||||
// d'effet de la révision la plus récente, ou la date de première échéance d'origine si
|
||||
// aucune révision n'a encore eu lieu).
|
||||
if ((type_remb || 'in_fine') === 'differe') {
|
||||
const startDate = date_cible || date_premiere_echeance || date_souscription;
|
||||
if (!startDate) return;
|
||||
const [echeance] = buildSchedule({
|
||||
montant: montant_investi, taux: taux_interet, duree: duree_mois,
|
||||
type: 'differe', freq: freq_interets || 'in_fine', startDate, finDeMois,
|
||||
});
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
|
||||
db.prepare(`
|
||||
INSERT INTO simul_remboursements
|
||||
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`).run(id, 1, echeance.date, echeance.capital, echeance.interets, echeance.total);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const echeances = buildSchedule({
|
||||
montant: montant_investi,
|
||||
taux: taux_interet,
|
||||
duree: effectiveDuree,
|
||||
type: type_remb || 'in_fine',
|
||||
freq: freq_interets || 'mensuel',
|
||||
startDate,
|
||||
finDeMois: !!echeance_fin_de_mois,
|
||||
});
|
||||
const isRestructuration = !!date_debut_simul;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (date_debut_simul) {
|
||||
// ── Mode restructuration ──────────────────────────────────────────────
|
||||
// En mode restructuration, on a besoin de connaître les échéances déjà honorées
|
||||
// AVANT de calculer la date de départ de la suite de l'échéancier (cf. plus bas) —
|
||||
// simple lecture, sans effet de bord, peut se faire hors transaction.
|
||||
let keptEntries = [];
|
||||
if (isRestructuration) {
|
||||
// Conserver uniquement les échéances déjà payées avant la date de restructuration
|
||||
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
|
||||
// Les échéances de la période creuse (non payées entre fin de la phase initiale
|
||||
// et date_debut_simul) sont supprimées avec tout ce qui suit.
|
||||
const keptEntries = db.prepare(`
|
||||
// et date_debut_simul) seront supprimées avec tout ce qui suit.
|
||||
keptEntries = db.prepare(`
|
||||
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
|
||||
WHERE sr.investissement_id = ?
|
||||
AND sr.date_prevue < ?
|
||||
@@ -571,7 +696,57 @@ export function generateSimul(db, inv) {
|
||||
)
|
||||
ORDER BY sr.numero_echeance
|
||||
`).all(id, date_debut_simul);
|
||||
}
|
||||
|
||||
let startDate, effectiveDuree = duree_mois, elapsedMonths = 0;
|
||||
|
||||
if (isRestructuration) {
|
||||
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
|
||||
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
|
||||
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
|
||||
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
|
||||
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
|
||||
const dateBasedElapsed = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
|
||||
? monthsDiff(date_premiere_echeance, date_debut_simul)
|
||||
: keptEntries.length; // fallback : nombre de lignes conservées
|
||||
|
||||
// Garde-fou anti-collision : le calcul calendaire ci-dessus peut sous-estimer le nombre
|
||||
// d'échéances déjà écoulées (ex. date_debut_simul tombe le même mois que la dernière
|
||||
// échéance déjà honorée, avant que le mois suivant ne soit atteint), auquel cas la
|
||||
// numérotation reprendrait à un numero_echeance déjà utilisé par une échéance conservée
|
||||
// ci-dessus, provoquant une violation de la contrainte UNIQUE(investissement_id,
|
||||
// numero_echeance) à l'INSERT. On ne redescend donc jamais en dessous du plus haut
|
||||
// numero_echeance déjà conservé.
|
||||
const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0);
|
||||
elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero);
|
||||
effectiveDuree = Math.max(1, duree_mois - elapsedMonths);
|
||||
|
||||
// Date de départ de la suite de l'échéancier : on continue la cadence mensuelle
|
||||
// d'origine (même jour du mois que date_premiere_echeance) à partir de la première
|
||||
// échéance pas encore écoulée — on ne repart PAS littéralement de date_debut_simul,
|
||||
// qui n'est que la date d'effet administrative de la révision (ex. le jour où
|
||||
// l'utilisateur l'a saisie) et peut tomber le même mois qu'une échéance déjà honorée,
|
||||
// ce qui créerait deux échéances dans le même mois (cf. bug signalé par Olivier le
|
||||
// 22/08/26 sur le dossier "L'Olympique" : échéances 19 et 20 incohérentes).
|
||||
const cadenceBase = date_premiere_echeance || date_debut_simul;
|
||||
startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths);
|
||||
} else {
|
||||
startDate = date_premiere_echeance || date_souscription;
|
||||
if (!startDate) return;
|
||||
}
|
||||
|
||||
const echeances = buildSchedule({
|
||||
montant: montant_investi,
|
||||
taux: taux_interet,
|
||||
duree: effectiveDuree,
|
||||
type: type_remb || 'in_fine',
|
||||
freq: freq_interets || 'mensuel',
|
||||
startDate,
|
||||
finDeMois,
|
||||
});
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (isRestructuration) {
|
||||
if (keptEntries.length > 0) {
|
||||
// Supprime tout sauf les entrées payées conservées
|
||||
db.prepare(
|
||||
@@ -582,15 +757,6 @@ export function generateSimul(db, inv) {
|
||||
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
|
||||
}
|
||||
|
||||
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
|
||||
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
|
||||
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
|
||||
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
|
||||
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
|
||||
const elapsedMonths = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
|
||||
? monthsDiff(date_premiere_echeance, date_debut_simul)
|
||||
: keptEntries.length; // fallback : nombre de lignes conservées
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO simul_remboursements
|
||||
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -67,6 +67,7 @@ echo "🔨 Build et redémarrage des containers..."
|
||||
# Mémoriser les IDs des anciennes images avant le build
|
||||
OLD_BACKEND_IMG=$(docker inspect --format='{{.Image}}' crowdlending-backend 2>/dev/null || true)
|
||||
OLD_FRONTEND_IMG=$(docker inspect --format='{{.Image}}' crowdlending-frontend 2>/dev/null || true)
|
||||
OLD_MCP_IMG=$(docker inspect --format='{{.Image}}' crowdlending-mcp 2>/dev/null || true)
|
||||
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
@@ -74,7 +75,7 @@ docker compose up -d
|
||||
# Purger les anciennes images devenues dangling
|
||||
echo ""
|
||||
echo "🧹 Purge des anciennes images..."
|
||||
for OLD_IMG in "$OLD_BACKEND_IMG" "$OLD_FRONTEND_IMG"; do
|
||||
for OLD_IMG in "$OLD_BACKEND_IMG" "$OLD_FRONTEND_IMG" "$OLD_MCP_IMG"; do
|
||||
if [ -n "$OLD_IMG" ]; then
|
||||
# Supprimer uniquement si l'image n'est plus utilisée par aucun container
|
||||
if ! docker ps -aq --filter "ancestor=$OLD_IMG" | grep -q .; then
|
||||
|
||||
|
After Width: | Height: | Size: 275 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 422 B |