Amélioration de la gestion du patrimoine
This commit is contained in:
1 parent
efa5de79b8
commit
eea69d77a4
22 files changed
+3212
-71
No files matched your search
+274
-11
@@ -4328,9 +4328,20 @@ db.exec(`
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_regroupatr_parent ON regroupements_patrimoniaux(parent_id)');
|
||||
|
||||
// Seed idempotent (n'insère que si la table est vide — l'admin reste libre de renommer/
|
||||
// réorganiser ensuite, comme pour les catégories). 7 regroupements racine "actif" + 1
|
||||
// "passif" (Passif, pour les emprunts), puis 1 sous-regroupement "Private Equity" rattaché à
|
||||
// "Comptes d'investissement" (inséré dans un second temps, une fois l'id parent connu).
|
||||
// réorganiser ensuite, comme pour les catégories). 6 regroupements racine "actif" + 1
|
||||
// "passif" (Passif, pour les emprunts), puis des sous-regroupements : "Private Equity",
|
||||
// "Crowdlending" et "Bourse" sous "Comptes d'investissement" ; "Comptes courants rémunérés" et
|
||||
// "Comptes courants non rémunérés" sous "Comptes courants" (insérés dans un second temps, une
|
||||
// fois les id parents connus). Crowdlending a d'abord été seedé en racine (28/09/26), puis
|
||||
// réintégré comme sous-catégorie de "Comptes d'investissement" le 30/09/26, à la demande
|
||||
// d'Olivier — même statut que Private Equity : *"Finalement je voudrais réintégrer le
|
||||
// Crowdlending comme sous-catégorie de comptes d'investissement."* "Bourse" (PEA/PEA-PME/CTO)
|
||||
// et la scission "rémunérés"/"non rémunérés" des comptes courants ont été créées directement en
|
||||
// sous-catégories le 30/09/26 (suite), mêmes demandes : *"J'aurais bien vu les PEA, PEA-PME,
|
||||
// CTO dans un sous-catégorie qui pourrait être 'Bourse'"* puis *"Et mettra aussi en place un
|
||||
// distinction entre comptes courants rémunérés et non rémunérés."* (Les installations
|
||||
// existantes sont reparentées/complétées par les blocs de migration ci-dessous, pas par ce
|
||||
// seed qui ne s'exécute qu'une fois, à vide.)
|
||||
if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n === 0) {
|
||||
const REGROUPEMENTS_RACINE = [
|
||||
{ nom: 'Immobilier', type: 'actif', ordre: 1 },
|
||||
@@ -4338,9 +4349,8 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n =
|
||||
{ nom: "Comptes d'épargne", type: 'actif', ordre: 3 },
|
||||
{ nom: 'Crypto', type: 'actif', ordre: 4 },
|
||||
{ nom: "Comptes d'investissement", type: 'actif', ordre: 5 },
|
||||
{ nom: 'Crowdlending', type: 'actif', ordre: 6 },
|
||||
{ nom: 'Autres actifs', type: 'actif', ordre: 7 },
|
||||
{ nom: 'Passif', type: 'passif', ordre: 8 },
|
||||
{ nom: 'Autres actifs', type: 'actif', ordre: 6 },
|
||||
{ nom: 'Passif', type: 'passif', ordre: 7 },
|
||||
];
|
||||
const insRoot = db.prepare(
|
||||
'INSERT INTO regroupements_patrimoniaux (nom, type, ordre_affichage) VALUES (?, ?, ?)'
|
||||
@@ -4349,12 +4359,107 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n =
|
||||
db.transaction(() => {
|
||||
for (const r of REGROUPEMENTS_RACINE) insRoot.run(r.nom, r.type, r.ordre);
|
||||
const comptesInv = getByNom.get("Comptes d'investissement");
|
||||
db.prepare(
|
||||
const insChild = db.prepare(
|
||||
'INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES (?, ?, ?, ?)'
|
||||
).run('Private Equity', comptesInv.id, 'actif', 1);
|
||||
);
|
||||
insChild.run('Private Equity', comptesInv.id, 'actif', 1);
|
||||
insChild.run('Crowdlending', comptesInv.id, 'actif', 2);
|
||||
insChild.run('Bourse', comptesInv.id, 'actif', 3);
|
||||
const comptesCourants = getByNom.get('Comptes courants');
|
||||
insChild.run('Comptes courants non rémunérés', comptesCourants.id, 'actif', 1);
|
||||
insChild.run('Comptes courants rémunérés', comptesCourants.id, 'actif', 2);
|
||||
})();
|
||||
}
|
||||
|
||||
// Migration (30/09/26, demande Olivier — cf. commentaire ci-dessus et claude/
|
||||
// plan_regroupements_patrimoniaux.md) : réintègre "Crowdlending" comme sous-regroupement de
|
||||
// "Comptes d'investissement" sur une installation existante, où le seed ci-dessus (qui ne
|
||||
// s'exécute qu'à table vide) a déjà créé "Crowdlending" en racine (28/09/26). Gardée par
|
||||
// `parent_id IS NULL` sur la ligne "Crowdlending" elle-même (pas un backfill générique sur
|
||||
// toute la table — les autres regroupements restent volontairement en racine) : idempotente,
|
||||
// et ne rejoue jamais après le premier passage puisque parent_id ne sera alors plus NULL.
|
||||
// ordre_affichage aligné sur le seed ci-dessus (2, juste après Private Equity = 1) pour que
|
||||
// les deux chemins — nouvelle installation ou migration d'une installation existante —
|
||||
// convergent vers le même état final.
|
||||
{
|
||||
const cl = db.prepare("SELECT id, parent_id FROM regroupements_patrimoniaux WHERE nom = 'Crowdlending'").get();
|
||||
const comptesInv = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes d''investissement'").get();
|
||||
if (cl && comptesInv && cl.parent_id === null) {
|
||||
db.prepare(
|
||||
"UPDATE regroupements_patrimoniaux SET parent_id = ?, type = 'actif', ordre_affichage = 2, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(comptesInv.id, cl.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Migration (30/09/26 (suite), demande Olivier — cf. claude/plan_regroupements_patrimoniaux.md)
|
||||
// : crée "Bourse" comme sous-regroupement de "Comptes d'investissement" sur une installation
|
||||
// existante (le seed ci-dessus ne s'exécute qu'à table vide), et y déplace les 3 enveloppes
|
||||
// PEA/PEA-PME/CTO, jusqu'ici directement sous "Comptes d'investissement". Olivier : *"J'aurais
|
||||
// bien vu les PEA, PEA-PME, CTO dans un sous-catégorie qui pourrait être 'Bourse'."*
|
||||
//
|
||||
// Gardée en deux temps, chacun idempotent :
|
||||
// 1. Création de "Bourse" seulement si elle n'existe pas encore (par nom, comme tout
|
||||
// regroupement) — ordre_affichage=3, juste après Crowdlending=2, pour converger vers le
|
||||
// même état final que le seed sur une nouvelle installation.
|
||||
// 2. Déplacement des 3 enveloppes SEULEMENT si elles sont encore exactement là où l'ancienne
|
||||
// répartition les avait placées (`regroupement_patrimonial_id = comptesInv.id`) — jamais
|
||||
// un backfill générique sur `IS NULL` comme pour le rattachement initial : ces enveloppes
|
||||
// étaient déjà rattachées (à "Comptes d'investissement"), donc `IS NULL` ne les aurait pas
|
||||
// touchées. Ce guard plus précis évite en particulier d'écraser un rattachement qu'un
|
||||
// administrateur aurait déjà modifié manuellement vers un autre regroupement.
|
||||
{
|
||||
const comptesInv = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes d''investissement'").get();
|
||||
if (comptesInv) {
|
||||
let bourse = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Bourse'").get();
|
||||
if (!bourse) {
|
||||
db.prepare(
|
||||
"INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES ('Bourse', ?, 'actif', 3)"
|
||||
).run(comptesInv.id);
|
||||
bourse = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Bourse'").get();
|
||||
}
|
||||
const moveToBourse = db.prepare(
|
||||
"UPDATE enveloppes_referentiel SET regroupement_patrimonial_id = ? WHERE nom = ? AND regroupement_patrimonial_id = ?"
|
||||
);
|
||||
for (const envNom of ['PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)']) {
|
||||
moveToBourse.run(bourse.id, envNom, comptesInv.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migration (30/09/26 (suite), demande Olivier — cf. claude/plan_regroupements_patrimoniaux.md)
|
||||
// : scinde "Comptes courants" en deux sous-regroupements "Comptes courants non rémunérés" et
|
||||
// "Comptes courants rémunérés" sur une installation existante, même mécanique que "Bourse"
|
||||
// ci-dessus (création si absente, puis déplacement des enveloppes concernées, gardé par leur
|
||||
// rattachement actuel à "Comptes courants" pour ne jamais écraser un rattachement déjà modifié
|
||||
// manuellement). Olivier : *"Et mettra aussi en place un distinction entre comptes courants
|
||||
// rémunérés et non rémunérés."*
|
||||
{
|
||||
const comptesCourants = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants'").get();
|
||||
if (comptesCourants) {
|
||||
let ccNonRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants non rémunérés'").get();
|
||||
if (!ccNonRem) {
|
||||
db.prepare(
|
||||
"INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES ('Comptes courants non rémunérés', ?, 'actif', 1)"
|
||||
).run(comptesCourants.id);
|
||||
ccNonRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants non rémunérés'").get();
|
||||
}
|
||||
let ccRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants rémunérés'").get();
|
||||
if (!ccRem) {
|
||||
db.prepare(
|
||||
"INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES ('Comptes courants rémunérés', ?, 'actif', 2)"
|
||||
).run(comptesCourants.id);
|
||||
ccRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants rémunérés'").get();
|
||||
}
|
||||
const moveTo = db.prepare(
|
||||
"UPDATE enveloppes_referentiel SET regroupement_patrimonial_id = ? WHERE nom = ? AND regroupement_patrimonial_id = ?"
|
||||
);
|
||||
moveTo.run(ccNonRem.id, 'Compte courant non rémunéré', comptesCourants.id);
|
||||
for (const envNom of ['Compte courant rémunéré', 'Compte rémunéré nouvelle génération (fintech)']) {
|
||||
moveTo.run(ccRem.id, envNom, comptesCourants.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Colonne de rattachement sur enveloppes_referentiel — ALTER gardé par PRAGMA table_info,
|
||||
// même patron que eligible_compte/compte_reglement_par_defaut ci-dessus.
|
||||
{
|
||||
@@ -4370,11 +4475,18 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n =
|
||||
// précédente de ce même bloc (idempotent). Mapping construit en croisant les 46 noms exacts du
|
||||
// catalogue (SEED_ENVELOPPES + EXTRA_ENVELOPPES ci-dessus) avec le regroupement demandé par
|
||||
// Olivier ; répartition documentée dans claude/plan_regroupements_patrimoniaux.md (doc projet).
|
||||
// Mis à jour le 30/09/26 pour pointer directement vers les sous-regroupements "Bourse" et
|
||||
// "Comptes courants rémunérés"/"non rémunérés" plutôt que leurs parents — ce bloc ne concerne
|
||||
// que les installations neuves (regroupement_patrimonial_id encore NULL) ; les installations
|
||||
// existantes sont reparentées par les migrations dédiées ci-dessus (guard sur l'ancien parent,
|
||||
// pas sur IS NULL, puisque ces enveloppes étaient déjà rattachées).
|
||||
{
|
||||
const ENVELOPPE_REGROUPEMENT = {
|
||||
'Comptes courants': [
|
||||
'Compte courant non rémunéré', 'Compte courant rémunéré',
|
||||
'Compte rémunéré nouvelle génération (fintech)',
|
||||
'Comptes courants non rémunérés': [
|
||||
'Compte courant non rémunéré',
|
||||
],
|
||||
'Comptes courants rémunérés': [
|
||||
'Compte courant rémunéré', 'Compte rémunéré nouvelle génération (fintech)',
|
||||
],
|
||||
"Comptes d'épargne": [
|
||||
'Livret A', 'LDDS', 'LEP', 'Livret Jeune', 'CEL', 'PEL',
|
||||
@@ -4384,6 +4496,8 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n =
|
||||
'Assurance-vie monosupport (fonds euros)', 'Assurance-vie multisupport',
|
||||
'Contrat de capitalisation', 'Assurance-vie luxembourgeoise',
|
||||
'PER individuel', 'PER collectif / obligatoire (PERCOL, article 83)', 'PEE',
|
||||
],
|
||||
'Bourse': [
|
||||
'PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)',
|
||||
],
|
||||
'Private Equity': [
|
||||
@@ -4442,4 +4556,153 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n =
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : solde_eur sur comptes (chantier Workspace Patrimoine, 28/09/26) ───────────
|
||||
// Aucun champ monétaire n'existait sur `comptes` jusqu'ici (nom/banque/enveloppe sont purement
|
||||
// descriptifs) — bloquant pour la restitution patrimoniale consolidée demandée par Olivier
|
||||
// (workspace "Patrimoine", cf. plus bas) : sans montant, aucune vue "combien j'ai sur chaque
|
||||
// famille d'actifs" n'est possible. Saisie MANUELLE pour l'instant (décision Olivier,
|
||||
// AskUserQuestion "Ajouter un solde manuel sur comptes maintenant (recommandé)") — pas de
|
||||
// valorisation automatique (ex. dernier relevé, cours de bourse) : ce sera un chantier séparé
|
||||
// si besoin. Nullable et non rétroactif : un compte existant sans saisie affiche simplement
|
||||
// "non renseigné" plutôt que 0€, pour ne pas fausser silencieusement un total.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name);
|
||||
if (!cols.includes('solde_eur')) {
|
||||
db.exec('ALTER TABLE comptes ADD COLUMN solde_eur REAL');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : workspaces.type 'patrimoine' — singleton (chantier Workspace Patrimoine,
|
||||
// 28/09/26) ────────────────────────────────────────────────────────────────────────────────
|
||||
// Même régime que idx_workspaces_type_crowdlending_singleton ci-dessus : au plus UN workspace
|
||||
// de type 'patrimoine' (dashboard consolidé unique — décision Olivier, AskUserQuestion
|
||||
// "Singleton (recommandé)"). 'patrimoine' est ajouté à WORKSPACE_TYPES côté
|
||||
// routes/adminWorkspaces.js — cf. ce fichier pour le contexte complet de ce chantier
|
||||
// (project doc claude/plan_workspace_patrimoine.md).
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_type_patrimoine_singleton
|
||||
ON workspaces(type) WHERE type = 'patrimoine'
|
||||
`);
|
||||
|
||||
// ── Migration : lignes_patrimoine + transactions_patrimoine (chantier "Visualisation d'un
|
||||
// actif", relance Phase 3b, 30/09/26) ─────────────────────────────────────────────────────
|
||||
// La Phase 3b ("ligne de patrimoine") avait été explicitement mise en pause le 28/09/26 (cf.
|
||||
// claude/plan_referentiel_enveloppes_supports.md, "on attend pour la 3b pour l'instant") — le
|
||||
// cadrage à deux chemins de rattachement (compte existant, ou enveloppe rattachée directement
|
||||
// sans compte intermédiaire) y avait déjà été validé sans être implémenté. Olivier (30/09/26,
|
||||
// verbatim) : "j'aimerais travailler sur une notion de visualisation d'un actif [...] Cela
|
||||
// pourra servir par exemple d'interface de saisie de la valeur actuelle de l'actif." — relance
|
||||
// ce chantier. Cadrage complet dans claude/plan_visualisation_actif.md (project doc).
|
||||
//
|
||||
// `lignes_patrimoine` couvre le second chemin déjà validé (enveloppe rattachée directement,
|
||||
// sans compte) : une position patrimoniale concrète pour les enveloppes aujourd'hui sans aucun
|
||||
// moyen de recevoir une valeur (Immobilier, Autres actifs, Crypto hors exchange...) — ex. "Ma
|
||||
// résidence principale", "Montre de collection". Toujours rattachée à une enveloppe (jamais de
|
||||
// type libre), comme un compte l'est déjà. `institution_id` optionnel (ex. le notaire pour un
|
||||
// bien immobilier), sur le même principe non contraignant que comptes.institution_id.
|
||||
// `valeur_eur` est la valeur actuelle en repli/saisie directe — cf. transactions_patrimoine
|
||||
// ci-dessous pour le cas où elle est calculée depuis un historique de mouvements réels.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS lignes_patrimoine (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
|
||||
enveloppe_id INTEGER NOT NULL REFERENCES enveloppes_referentiel(id),
|
||||
institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL,
|
||||
nom TEXT NOT NULL,
|
||||
valeur_eur REAL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_lignespat_user ON lignes_patrimoine(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_lignespat_enveloppe ON lignes_patrimoine(enveloppe_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_lignespat_investisseur ON lignes_patrimoine(investisseur_id)');
|
||||
|
||||
// `transactions_patrimoine` — "de vrais mouvements (dépôt/retrait/achat/vente)" (Olivier,
|
||||
// 30/09/26, réponse à la question de cadrage sur la nature des transactions), sur le patron déjà
|
||||
// éprouvé de `depots_retraits` (cf. db/schema.sql), généralisé à une cible polymorphe : soit un
|
||||
// compte existant, soit une ligne de patrimoine, jamais les deux (CHECK ci-dessous) — même
|
||||
// principe d'exclusivité que le double chemin de rattachement Phase 3b. 5ème type "reevaluation"
|
||||
// ajouté au-delà des 4 cités par Olivier : un bien immobilier ou un objet de collection ne se
|
||||
// "dépose"/"retire" pas comme un compte, sa valeur change par appréciation/dépréciation
|
||||
// constatée plutôt que par un flux d'argent réel — sans ce type, la section Transactions d'un
|
||||
// tel actif resterait vide en pratique (cf. claude/plan_visualisation_actif.md, "Point à
|
||||
// valider #2"). Convention de signe sur `montant` : dépôt/achat = valeur ajoutée (positive),
|
||||
// retrait/vente = valeur retirée (positive, le retrait est appliqué en négatif au calcul de la
|
||||
// valeur courante), reevaluation = variation signée directe (peut être négative) — appliquée
|
||||
// telle quelle, pas de contrainte de signe en base sur ce type précis, donc pas de CHECK sur le
|
||||
// signe de `montant` lui-même (validation du signe attendu par type faite côté route/Zod).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS transactions_patrimoine (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
compte_id INTEGER REFERENCES comptes(id) ON DELETE CASCADE,
|
||||
ligne_patrimoine_id INTEGER REFERENCES lignes_patrimoine(id) ON DELETE CASCADE,
|
||||
date_operation TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('depot','retrait','achat','vente','reevaluation')),
|
||||
montant REAL NOT NULL,
|
||||
libelle TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK ((compte_id IS NOT NULL) + (ligne_patrimoine_id IS NOT NULL) = 1)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_transpat_compte ON transactions_patrimoine(compte_id, date_operation)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_transpat_ligne ON transactions_patrimoine(ligne_patrimoine_id, date_operation)');
|
||||
|
||||
// ── Migration : valorisations_patrimoine (chantier "Historique de valeur", 30/09/26, suite) ──
|
||||
// Olivier (verbatim) : "quand je rentre manuellement une valeur à un actif, je voudrais que tu
|
||||
// gardes quelque part une association avec une date de valeur. Cela va nous permettre de tracer
|
||||
// des graphiques de progression de la valeur dans le temps." Décidé via AskUserQuestion
|
||||
// (30/09/26) : table dédiée plutôt qu'une extension de transactions_patrimoine — même modèle
|
||||
// que investissements_pe_valorisations (Private Equity, 17/09/26), déjà éprouvé pour ce même
|
||||
// besoin (date + valeur + note, "dernière valorisation" = la plus récente par date). Cible
|
||||
// polymorphe compte_id / ligne_patrimoine_id (même patron d'exclusivité que
|
||||
// transactions_patrimoine ci-dessus) — emprunts explicitement hors périmètre (AskUserQuestion,
|
||||
// "Non, actifs uniquement"). comptes.solde_eur / lignes_patrimoine.valeur_eur restent la valeur
|
||||
// courante affichée partout (aucun changement de leur sémantique) ; chaque saisie manuelle y
|
||||
// ajoute désormais aussi un point d'historique, écrit par les routes (comptes.js,
|
||||
// lignesPatrimoine.js), pas ici. Pour une ligne_patrimoine qui a des transactions réelles
|
||||
// (dépôt/retrait/achat/vente/reevaluation), l'historique de valeur se reconstitue déjà depuis
|
||||
// transactions_patrimoine (somme cumulée datée) — cette nouvelle table ne sert alors pas au
|
||||
// graphique de cette ligne précise, mais reste écrite pour rester simple côté backend (elle est
|
||||
// juste ignorée côté frontend dans ce cas).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS valorisations_patrimoine (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
compte_id INTEGER REFERENCES comptes(id) ON DELETE CASCADE,
|
||||
ligne_patrimoine_id INTEGER REFERENCES lignes_patrimoine(id) ON DELETE CASCADE,
|
||||
date_valorisation TEXT NOT NULL,
|
||||
valeur REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK ((compte_id IS NOT NULL) + (ligne_patrimoine_id IS NOT NULL) = 1)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_valpat_compte ON valorisations_patrimoine(compte_id, date_valorisation)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_valpat_ligne ON valorisations_patrimoine(ligne_patrimoine_id, date_valorisation)');
|
||||
|
||||
// Backfill (toujours exécuté, gardé par une sous-requête NOT EXISTS) : un premier point
|
||||
// d'historique daté d'aujourd'hui pour chaque compte/ligne_patrimoine déjà valorisé avant la
|
||||
// mise en place de ce mécanisme — décidé via AskUserQuestion ("Backfill à aujourd'hui") plutôt
|
||||
// que de laisser l'historique démarrer vide. NOT EXISTS (pas seulement "table vide") : reste
|
||||
// idempotent même après que l'utilisateur a commencé à saisir ses propres points d'historique
|
||||
// pour d'AUTRES comptes/lignes — ne rejoue jamais sur une cible qui a déjà au moins un point.
|
||||
db.exec(`
|
||||
INSERT INTO valorisations_patrimoine (compte_id, date_valorisation, valeur)
|
||||
SELECT c.id, date('now'), c.solde_eur
|
||||
FROM comptes c
|
||||
WHERE c.solde_eur IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM valorisations_patrimoine v WHERE v.compte_id = c.id)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO valorisations_patrimoine (ligne_patrimoine_id, date_valorisation, valeur)
|
||||
SELECT lp.id, date('now'), lp.valeur_eur
|
||||
FROM lignes_patrimoine lp
|
||||
WHERE lp.valeur_eur IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM valorisations_patrimoine v WHERE v.ligne_patrimoine_id = lp.id)
|
||||
`);
|
||||
|
||||
export default db;
|
||||
@@ -18,9 +18,12 @@ const router = Router();
|
||||
// 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). Un 3e type nécessiterait d'abord une page/route dédiée avant
|
||||
// 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'];
|
||||
const WORKSPACE_TYPES = ['crowdlending', 'private_equity', 'patrimoine'];
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare(`
|
||||
@@ -53,12 +56,17 @@ router.post('/', (req, res, next) => {
|
||||
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) : cette
|
||||
// vérification applicative donne un message clair avant même d'atteindre la contrainte SQL.
|
||||
// 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)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { recordValorisation } from '../utils/valorisationsPatrimoine.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -21,6 +22,9 @@ const Schema = z.object({
|
||||
// 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) {
|
||||
@@ -33,7 +37,7 @@ function clearOtherDefaults(userId, investisseurId, excludeId) {
|
||||
router.get('/', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
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.created_at,
|
||||
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,
|
||||
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
|
||||
@@ -56,8 +60,9 @@ router.post('/', (req, res, next) => {
|
||||
const id = db.transaction(() => {
|
||||
if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id);
|
||||
const r = db.prepare(
|
||||
'INSERT INTO comptes (user_id, nom, banque, institution_id, enveloppe_id, investisseur_id, exoneration_fiscale, compte_reglement_par_defaut) 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);
|
||||
'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 });
|
||||
@@ -70,9 +75,11 @@ router.put('/:id', (req, res, next) => {
|
||||
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));
|
||||
return db.prepare(
|
||||
`UPDATE comptes SET nom=?, banque=?, institution_id=?, enveloppe_id=?, investisseur_id=?, exoneration_fiscale=?, compte_reglement_par_defaut=?, 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, req.params.id, req.user.id).changes;
|
||||
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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -35,8 +35,14 @@ function attachCategories(rows) {
|
||||
|
||||
/* 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, eligible_compte
|
||||
SELECT id, nom, description, eligible_compte, regroupement_patrimonial_id
|
||||
FROM enveloppes_referentiel
|
||||
ORDER BY nom
|
||||
`).all();
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -59,6 +59,7 @@ 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';
|
||||
@@ -72,6 +73,12 @@ 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';
|
||||
@@ -191,6 +198,7 @@ app.use('/api/enveloppes-referentiel-public', requireAuth, enveloppesReferentiel
|
||||
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);
|
||||
@@ -203,6 +211,10 @@ 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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user