328 lines
17 KiB
Plaintext
328 lines
17 KiB
Plaintext
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;
|