From 624d32ff7c826dfeed60a84344162618643a3345 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 27 Sep 2026 15:09:18 +0200 Subject: [PATCH] maj --- _to_delete/crowdlending_migration.py | 18 + _to_delete/dette_forestier_lmnp_migration.py | 36 + _to_delete/etc_migration.py | 47 ++ _to_delete/extra_actifs_2_migration.py | 84 ++ _to_delete/extra_actifs_migration.py | 71 ++ _to_delete/fonds_monetaires_migration.py | 58 ++ _to_delete/gestion_pilotee_fix_migration.py | 53 ++ .../regime_fiscal_generique_migration.py | 102 +++ backend/src/db/index.js | 417 ++++++++++ backend/src/routes/enveloppesReferentiel.js | 174 ++++ .../src/routes/ref-categories-enveloppes.js | 104 +++ backend/src/routes/supportsReferentiel.js | 102 +++ backend/src/server.js | 9 + frontend/src/App.jsx | 2 + frontend/src/pages/Admin.jsx | 2 + frontend/src/pages/AdminEnveloppes.jsx | 781 ++++++++++++++++++ frontend/src/pages/AdminInstitutions.jsx | 133 ++- 17 files changed, 2192 insertions(+), 1 deletion(-) create mode 100644 _to_delete/crowdlending_migration.py create mode 100644 _to_delete/dette_forestier_lmnp_migration.py create mode 100644 _to_delete/etc_migration.py create mode 100644 _to_delete/extra_actifs_2_migration.py create mode 100644 _to_delete/extra_actifs_migration.py create mode 100644 _to_delete/fonds_monetaires_migration.py create mode 100644 _to_delete/gestion_pilotee_fix_migration.py create mode 100644 _to_delete/regime_fiscal_generique_migration.py create mode 100644 backend/src/routes/enveloppesReferentiel.js create mode 100644 backend/src/routes/ref-categories-enveloppes.js create mode 100644 backend/src/routes/supportsReferentiel.js create mode 100644 frontend/src/pages/AdminEnveloppes.jsx diff --git a/_to_delete/crowdlending_migration.py b/_to_delete/crowdlending_migration.py new file mode 100644 index 0000000..0a5a657 --- /dev/null +++ b/_to_delete/crowdlending_migration.py @@ -0,0 +1,18 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +anchor = """ { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + ];""" +assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}" + +new_item = """ { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] }, + ];""" + +content = content.replace(anchor, new_item, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Crowdlending enveloppe inserted OK, new length:", len(content)) diff --git a/_to_delete/dette_forestier_lmnp_migration.py b/_to_delete/dette_forestier_lmnp_migration.py new file mode 100644 index 0000000..2cbc6b6 --- /dev/null +++ b/_to_delete/dette_forestier_lmnp_migration.py @@ -0,0 +1,36 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +# 1) Nouvelle catégorie "Investissement forestier & viticole" (GFF + GFV) ajoutée à la liste des +# catégories toujours insérées (INSERT OR IGNORE). +anchor_cat = " for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection']) {" +assert content.count(anchor_cat) == 1, f"anchor_cat count={content.count(anchor_cat)}" +new_cat = " for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection', 'Investissement forestier & viticole']) {" +content = content.replace(anchor_cat, new_cat, 1) + +# 2) 7 nouvelles enveloppes ajoutées à EXTRA_ENVELOPPES (demande Olivier du 27/09/26, à partir +# d'une capture d'écran d'un site de conseil en gestion de patrimoine listant des placements +# absents du référentiel) : Dette privée, capital-investissement réservé (FPCI/SLP/FPS), +# Infrastructures non cotées, Groupement Foncier Forestier, Groupement Foncier Viticole, +# LMNP/LMP (distinct de l'immobilier locatif nu déjà présent), SCPI en démembrement. +anchor_last_item = """ { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] }, + ];""" +assert content.count(anchor_last_item) == 1, f"anchor_last_item count={content.count(anchor_last_item)}" + +new_items = """ { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] }, + { nom: 'Dette privée (fonds)', categories: ['Financement participatif / private equity'], description: "Fonds de dette privée (private debt) — prêts directs à des entreprises via un véhicule de fonds professionnel, hors plateforme de crowdlending grand public.", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] }, + { nom: 'Capital-investissement réservé (FPCI / SLP / FPS)', categories: ['Financement participatif / private equity'], description: "Fonds de capital-investissement réservés aux investisseurs avertis/professionnels — Fonds Professionnel de Capital Investissement (FPCI), Société de Libre Partenariat (SLP), Fonds Professionnel Spécialisé (FPS) ; distinct du FCPR grand public (support disponible en assurance-vie/PER/contrat de capitalisation) et de la participation directe non cotée (prise de participation en direct, hors fonds).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les plus-values de cession de parts (flat tax / PFU, ou option barème) ; exonération possible sous conditions de durée de détention, comme pour un FCPR", supports: [] }, + { nom: 'Infrastructures non cotées', categories: ['Financement participatif / private equity'], description: "Fonds d'investissement dans des actifs d'infrastructure non cotés (énergie, transport, télécoms...).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] }, + { nom: 'Groupement Foncier Forestier (GFF)', categories: ['Investissement forestier & viticole'], description: "Investissement forestier via un groupement foncier forestier — parts donnant une quote-part de bois et forêts en gestion collective.", plafond_eur: null, regime_fiscal: "Réduction d'IR à l'entrée possible sous conditions (dispositif DEFI-Forêt), exonération partielle d'IFI, transmission facilitée sous conditions d'engagement de gestion durable", supports: [] }, + { nom: 'Groupement Foncier Viticole (GFV)', categories: ['Investissement forestier & viticole'], description: "Investissement viticole via un groupement foncier viticole — parts donnant une quote-part de vignes louées à un exploitant via un bail rural.", plafond_eur: null, regime_fiscal: "Revenus fonciers (loyers du bail rural), exonération partielle d'IFI et de droits de succession/donation sous conditions", supports: [] }, + { nom: 'Immobilier locatif meublé (LMNP/LMP)', categories: ['Immobilier physique'], description: "Location meublée en direct — statut Loueur en Meublé Non Professionnel (LMNP) ou Professionnel (LMP) selon les seuils de recettes et la composition du foyer fiscal ; distinct de « Immobilier locatif (détention directe) » qui suppose une location nue (revenus fonciers).", plafond_eur: null, regime_fiscal: "Revenus imposés en BIC (micro-BIC ou régime réel avec amortissement du bien) ; le statut LMP ouvre en plus à l'imputation des déficits sur le revenu global et à un régime de plus-value professionnelle", supports: [] }, + { nom: 'SCPI en démembrement (nue-propriété / usufruit temporaire)', categories: ['Immobilier papier'], description: "SCPI achetée en démembrement de propriété (nue-propriété ou usufruit temporaire), typiquement sur 5 à 10 ans.", plafond_eur: null, regime_fiscal: "Usufruitier : perçoit les loyers et est imposé en revenus fonciers pendant la durée du démembrement. Nu-propriétaire : aucun revenu ni imposition pendant la période, récupère la pleine propriété sans fiscalité à l'extinction ; non redevable de l'IFI sur la valeur du bien démembré pendant la durée (sauf exceptions)", supports: [] }, + ];""" + +content = content.replace(anchor_last_item, new_items, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Dette privée / FPCI-SLP-FPS / Infrastructures / GFF / GFV / LMNP-LMP / SCPI démembrement inserted OK, new length:", len(content)) diff --git a/_to_delete/etc_migration.py b/_to_delete/etc_migration.py new file mode 100644 index 0000000..a11bfb3 --- /dev/null +++ b/_to_delete/etc_migration.py @@ -0,0 +1,47 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +anchor = "\nexport default db;\n" +assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}" + +block = ''' +// Extension du 29/09/26 (suite) — support "ETC" (demande Olivier, à partir d'une capture d'écran +// d'un contrat d'assurance-vie distinguant "+200 ETF & 22 ETC" parmi ses supports) : un Exchange +// Traded Commodity (ETC) est un instrument coté qui réplique le cours d'une matière première +// (or, pétrole...), proche d'un ETF dans sa mécanique de cotation mais sans statut UCITS/OPCVM — +// distinct du support "ETF / trackers" (fonds indiciels UCITS) et de "Matières premières / métaux +// précieux" (autre modalité d'exposition aux matières premières, ex. en unités de compte +// d'assurance-vie). Rattaché aux mêmes enveloppes que "Fonds monétaires" ci-dessus (menu de +// supports déjà large) ; PEA/PEA-PME exclus, comme en pratique un ETC matières premières +// physiques n'est pas éligible au PEA. Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom). +{ + db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run( + 'ETC (Exchange Traded Commodities)', + "Tracker coté sur une matière première (or, pétrole, panier de matières premières...) — proche de l'ETF dans sa mécanique de cotation, mais sans statut UCITS/OPCVM ; distinct du support « ETF / trackers » et de « Matières premières / métaux précieux »." + ); + const getSuppRefIdEtc = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?'); + const suppEtc = getSuppRefIdEtc.get('ETC (Exchange Traded Commodities)'); + if (suppEtc) { + const getEnvRefIdEtc = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const linkEnvSuppEtc = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)'); + for (const envNom of [ + 'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel', + 'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation', + 'Assurance-vie luxembourgeoise', + ]) { + const env = getEnvRefIdEtc.get(envNom); + if (env) linkEnvSuppEtc.run(env.id, suppEtc.id); + } + } +} + +export default db; +''' + +content = content.replace(anchor, block, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Support ETC inserted OK, new length:", len(content)) diff --git a/_to_delete/extra_actifs_2_migration.py b/_to_delete/extra_actifs_2_migration.py new file mode 100644 index 0000000..8868da7 --- /dev/null +++ b/_to_delete/extra_actifs_2_migration.py @@ -0,0 +1,84 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +# 1) Insérer la liste des 11 supports (dupliquée localement, hors de portée du AV_PER_SUPPORTS +# du bloc de seed initial) juste avant EXTRA_ENVELOPPES. +anchor_supports = " const EXTRA_ENVELOPPES = [\n" +assert content.count(anchor_supports) == 1, f"anchor_supports count={content.count(anchor_supports)}" +supports_const = """ const AV_PER_SUPPORTS_EXTRA = [ + 'Fonds en euros', 'OPCVM / fonds actifs', 'ETF / trackers', 'Titres vifs (actions)', + 'Obligations / fonds datés', 'Immobilier papier (SCPI/SCI/OPCI)', 'Private equity (FCPR)', + 'Produits structurés', 'Matières premières / métaux précieux', 'Gestion pilotée / mandat', + 'Fonds solidaires (90/10)', + ]; + +""" +content = content.replace(anchor_supports, supports_const + anchor_supports, 1) + +# 2) Ajouter les nouvelles enveloppes à la fin du tableau EXTRA_ENVELOPPES : Contrat de +# capitalisation, Assurance-vie luxembourgeoise, Girardin (demande du 29/09/26 sur les +# enveloppes manquantes / actifs "exotiques"), et Wallet crypto conservé par un tiers +# (demande du 29/09/26, distincte de l'auto-conservation et du compte sur exchange). +anchor_last_item = """ { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] }, + ];""" +assert content.count(anchor_last_item) == 1, f"anchor_last_item count={content.count(anchor_last_item)}" + +new_items = """ { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] }, + { nom: 'Contrat de capitalisation', categories: ['Assurance-vie'], description: "Support d'épargne proche de l'assurance-vie (fonds euros + unités de compte), mais sans clause bénéficiaire ni dénouement au décès — entre dans la succession classique. Peut être souscrit par une personne physique ou une personne morale (société, SCI...).", plafond_eur: null, regime_fiscal: "Fiscalité des gains identique à l'assurance-vie pour un particulier (abattement après 8 ans, PFU ou PFL) ; pas de régime de succession spécifique (articles 990 I / 757 B) ; transmissible par donation du vivant, contrairement à l'assurance-vie", supports: AV_PER_SUPPORTS_EXTRA }, + { nom: 'Assurance-vie luxembourgeoise', categories: ['Assurance-vie'], description: "Contrat d'assurance-vie souscrit auprès d'un assureur luxembourgeois — triangle de sécurité (actifs ségrégués auprès d'une banque dépositaire distincte de l'assureur), univers d'investissement et devises généralement plus larges qu'un contrat français.", plafond_eur: null, regime_fiscal: "Fiscalité identique à l'assurance-vie française pour un résident fiscal français (abattement après 8 ans, PFU/PFL) ; déclaration du contrat étranger requise (formulaire 3916-3916 bis)", supports: AV_PER_SUPPORTS_EXTRA }, + { nom: 'Girardin (industriel / social)', categories: ['Financement participatif / private equity'], description: "Investissement dans un dispositif de défiscalisation outre-mer — financement d'un investissement productif ou de logement social en DOM-TOM, en échange d'une réduction d'impôt immédiate. Capital non récupéré (pas de valeur résiduelle), contrairement à un FCPI/FIP.", plafond_eur: null, regime_fiscal: "Réduction d'IR l'année suivant l'investissement, généralement supérieure au montant investi (effet de levier) ; plafonnement spécifique des niches fiscales outre-mer ; risque de remise en cause fiscale si le monteur/exploitant ne respecte pas ses engagements (durée de conservation 5 ans)", supports: [] }, + { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + ];""" + +content = content.replace(anchor_last_item, new_items, 1) + +# 3) Étendre la boucle de traitement pour gérer aussi les supports (jusqu'ici uniquement les +# catégories, car aucune des 7 enveloppes du 29/09/26 n'avait de support). +anchor_loop = """ const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)'); + const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?'); + const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)'); + db.transaction(() => { + for (const { nom, description, plafond_eur, regime_fiscal, categories } of EXTRA_ENVELOPPES) { + insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null); + const env = getEnvRefIdExtra.get(nom); + if (!env) continue; + for (const catNom of categories) { + const cat = getCatEnvIdExtra.get(catNom); + if (cat) linkEnvCatExtra.run(env.id, cat.id); + } + } + })(); +}""" +assert content.count(anchor_loop) == 1, f"anchor_loop count={content.count(anchor_loop)}" + +new_loop = """ const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)'); + const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?'); + const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)'); + const getSuppRefIdExtra = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?'); + const linkEnvSuppExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)'); + db.transaction(() => { + for (const { nom, description, plafond_eur, regime_fiscal, categories, supports } of EXTRA_ENVELOPPES) { + insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null); + const env = getEnvRefIdExtra.get(nom); + if (!env) continue; + for (const catNom of categories) { + const cat = getCatEnvIdExtra.get(catNom); + if (cat) linkEnvCatExtra.run(env.id, cat.id); + } + for (const suppNom of (supports || [])) { + const supp = getSuppRefIdExtra.get(suppNom); + if (supp) linkEnvSuppExtra.run(env.id, supp.id); + } + } + })(); +}""" + +content = content.replace(anchor_loop, new_loop, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Extension 2 (contrat de capitalisation, AV luxembourgeoise, Girardin, wallet tiers) inserted OK, new length:", len(content)) diff --git a/_to_delete/extra_actifs_migration.py b/_to_delete/extra_actifs_migration.py new file mode 100644 index 0000000..2657a7f --- /dev/null +++ b/_to_delete/extra_actifs_migration.py @@ -0,0 +1,71 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +anchor = """db.prepare(` + UPDATE enveloppes_referentiel SET plafond_eur = 61200, updated_at = datetime('now') + WHERE nom = 'PEL' AND plafond_eur IS NULL +`).run(); + +export default db; +""" +assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}" + +block = '''db.prepare(` + UPDATE enveloppes_referentiel SET plafond_eur = 61200, updated_at = datetime('now') + WHERE nom = 'PEL' AND plafond_eur IS NULL +`).run(); + +// Extension du 29/09/26 (demande Olivier) : "je ne voudrais pas faire un nième référentiel [...] +// il faut peut-être que cela soit des catégories supplémentaires même si ce n'a pas tout à fait +// le même statut" — plutôt qu'un 3e référentiel séparé pour les actifs "physiques"/hors +// enveloppe fiscale (immobilier direct, crypto, métaux précieux, objets de collection, +// participations non cotées), ils sont intégrés au référentiel existant : quelques nouvelles +// catégories (familles) dans categories_enveloppes, et des enveloppes rattachées, sur le même +// principe que les enveloppes SCPI/OPCI/SCI détenues en direct déjà présentes (aucun support — +// détention directe, pas de menu de supports ; aucun institution_id — catalogue seul, comme le +// reste de ce référentiel). Les participations non cotées réutilisent la catégorie existante +// "Financement participatif / private equity" plutôt que d'en créer une nouvelle (même famille +// que le Crowdequity et les FCPI/FIP). Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom) +// — s'ajoute sans écraser à un référentiel déjà seedé, comme le backfill CEL/PEL ci-dessus. +{ + const insCatEnvExtra = db.prepare('INSERT OR IGNORE INTO categories_enveloppes (nom) VALUES (?)'); + for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection']) { + insCatEnvExtra.run(nom); + } + + const EXTRA_ENVELOPPES = [ + { nom: 'Résidence principale', categories: ['Immobilier physique'], description: "Logement occupé à titre de résidence principale.", plafond_eur: null, regime_fiscal: "Plus-value de cession exonérée (résidence principale)", supports: [] }, + { nom: 'Immobilier locatif (détention directe)', categories: ['Immobilier physique'], description: "Bien immobilier loué, détenu en direct (hors SCPI/SCI).", plafond_eur: null, regime_fiscal: "Revenus fonciers (micro-foncier ou réel) + plus-value immobilière (abattement selon durée de détention)", supports: [] }, + { nom: 'Compte crypto (exchange)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues sur une plateforme d'échange centralisée.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + { nom: 'Wallet crypto (auto-conservation)', categories: ['Actifs numériques'], description: "Cryptomonnaies en auto-conservation (wallet matériel ou logiciel).", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + { nom: 'Métaux précieux physiques', categories: ['Métaux précieux'], description: "Or, argent... détenus physiquement (lingots, pièces).", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les métaux précieux (11 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] }, + { nom: 'Objets de collection', categories: ['Objets de collection'], description: "Montres, art, antiquités...", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les objets d'art/collection/antiquité (6 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] }, + { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] }, + ]; + const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)'); + const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?'); + const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)'); + db.transaction(() => { + for (const { nom, description, plafond_eur, regime_fiscal, categories } of EXTRA_ENVELOPPES) { + insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null); + const env = getEnvRefIdExtra.get(nom); + if (!env) continue; + for (const catNom of categories) { + const cat = getCatEnvIdExtra.get(catNom); + if (cat) linkEnvCatExtra.run(env.id, cat.id); + } + } + })(); +} + +export default db; +''' + +content = content.replace(anchor, block, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Extension actifs directs inserted OK, new length:", len(content)) diff --git a/_to_delete/fonds_monetaires_migration.py b/_to_delete/fonds_monetaires_migration.py new file mode 100644 index 0000000..51095d1 --- /dev/null +++ b/_to_delete/fonds_monetaires_migration.py @@ -0,0 +1,58 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +anchor = """ })(); +} + +export default db; +""" +assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}" + +block = ''' })(); +} + +// Extension du 29/09/26 (demande Olivier, à propos de produits Spiko/Regular Finance) : "sais-tu +// comment les classifier ?" — Spiko et Regular Finance ne sont pas des enveloppes mais des +// PLATEFORMES/INSTITUTIONS donnant accès à un fonds monétaire (OPCVM monétaire, y compris +// lorsqu'il est tokenisé sur blockchain comme chez Spiko) : elles ont vocation à rejoindre +// institutions_referentiel (catégorie à créer, ex. "Gestion de trésorerie"), pas +// enveloppes_referentiel — le rattachement viendra en Phase 3. Ce qui manquait réellement à CE +// référentiel, c'est le SUPPORT lui-même : "OPCVM / fonds actifs" existant désigne des fonds +// actions/obligations/diversifiés gérés activement, pas un fonds monétaire (quasi-cash, duration +// très courte, profil de risque très différent) — les deux ne doivent pas être confondus. +// Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom), même principe que les extensions +// précédentes. +{ + db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run( + 'Fonds monétaires (OPCVM monétaire)', + "Fonds monétaire classique ou tokenisé sur blockchain (ex. Spiko, Regular Finance) — quasi-cash, duration très courte, distinct d'un OPCVM actions/obligations/diversifié." + ); + const getSuppRefIdMonetaire = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?'); + const suppMonetaire = getSuppRefIdMonetaire.get('Fonds monétaires (OPCVM monétaire)'); + if (suppMonetaire) { + const getEnvRefIdMonetaire = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const linkEnvSuppMonetaire = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)'); + // Enveloppes où un fonds monétaire a sa place (menu de supports déjà large, cohérent avec un + // placement défensif) — PEA/PEA-PME exclus (non éligibles réglementairement), enveloppes + // "cash" (Livret A, comptes courants...) exclues (déjà sans menu de supports par nature). + for (const envNom of [ + 'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel', + 'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation', + 'Assurance-vie luxembourgeoise', + ]) { + const env = getEnvRefIdMonetaire.get(envNom); + if (env) linkEnvSuppMonetaire.run(env.id, suppMonetaire.id); + } + } +} + +export default db; +''' + +content = content.replace(anchor, block, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Fonds monétaires support + links inserted OK, new length:", len(content)) diff --git a/_to_delete/gestion_pilotee_fix_migration.py b/_to_delete/gestion_pilotee_fix_migration.py new file mode 100644 index 0000000..b5f9191 --- /dev/null +++ b/_to_delete/gestion_pilotee_fix_migration.py @@ -0,0 +1,53 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +# 1) Retrait de "Gestion pilotée / mandat" du seed initial des supports (SEED_SUPPORTS) — ne +# concerne que les installations neuves, ce bloc étant gardé par COUNT()===0. +old_seed_line = " { nom: 'Gestion pilotée / mandat', description: 'Allocation déléguée à un gérant selon un profil de risque — mode par défaut de nombreux PER.' },\n" +assert content.count(old_seed_line) == 1, f"old_seed_line count={content.count(old_seed_line)}" +content = content.replace(old_seed_line, "", 1) + +# 2) Retrait de 'Gestion pilotée / mandat' des deux listes de supports "classiques" AV/PER +# (AV_PER_SUPPORTS et AV_PER_SUPPORTS_EXTRA) — présent dans les deux car dupliqué (la seconde +# liste, hors de portée du bloc de seed initial, a été recopiée lors de l'extension du 29/09/26 +# pour Contrat de capitalisation / Assurance-vie luxembourgeoise). +old_list_fragment = "'Produits structurés', 'Matières premières / métaux précieux', 'Gestion pilotée / mandat'," +assert content.count(old_list_fragment) == 2, f"old_list_fragment count={content.count(old_list_fragment)}" +new_list_fragment = "'Produits structurés', 'Matières premières / métaux précieux'," +content = content.replace(old_list_fragment, new_list_fragment) # replace_all (2 occurrences) + +# 3) Nouveau bloc toujours exécuté : supprime la ligne "Gestion pilotée / mandat" pour les +# installations déjà seedées (le retrait du texte source ci-dessus ne corrige que les +# installations neuves). ON DELETE CASCADE sur enveloppes_supports_disponibles nettoie +# automatiquement les liens existants vers ce support — c'est un pur catalogue (aucune donnée +# d'investissement réelle n'y est aujourd'hui rattachée), donc la suppression est sans risque. +# Toujours exécuté et idempotent (DELETE sans WHERE de garde spécifique au-delà du nom — un +# deuxième passage ne trouve simplement plus rien à supprimer). +anchor = "\nexport default db;\n" +assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}" + +block = ''' +// Correctif du 29/09/26 (suite) — retrait du support "Gestion pilotée / mandat" (demande +// Olivier, à propos d'une capture d'écran distinguant "3 modes de gestion" — libre / pilotée / +// mixte — d'un contrat d'assurance-vie) : le "mode de gestion" décrit QUI décide de l'allocation +// (le titulaire, ou un gérant délégué selon un mandat), c'est une dimension orthogonale au +// SUPPORT (le type d'instrument financier détenu — ETF, actions, SCPI...). Une gestion pilotée +// s'applique justement à un panier de vrais supports (fonds euros, fonds datés, SCPI, produits +// structurés...), elle n'en est pas un elle-même. Cette ligne datait du seed initial du 28/09/26 +// et n'aurait pas dû être modélisée comme support ; elle est retirée du référentiel et de la +// liste de supports "classiques" AV/PER (voir plus haut). Toujours exécuté et idempotent — +// DELETE cascade automatiquement les liens vers cette entrée (enveloppes_supports_disponibles, +// ON DELETE CASCADE) ; aucune donnée d'investissement réelle n'est rattachée à ce catalogue à ce +// stade, la suppression est donc sans risque de perte de données utilisateur. +db.prepare("DELETE FROM supports_referentiel WHERE nom = 'Gestion pilotée / mandat'").run(); + +export default db; +''' + +content = content.replace(anchor, block, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Gestion pilotée / mandat removed from supports OK, new length:", len(content)) diff --git a/_to_delete/regime_fiscal_generique_migration.py b/_to_delete/regime_fiscal_generique_migration.py new file mode 100644 index 0000000..7672b71 --- /dev/null +++ b/_to_delete/regime_fiscal_generique_migration.py @@ -0,0 +1,102 @@ +path = "backend/src/db/index.js" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +# Liste des 12 lignes à corriger : (nom, ancien texte exact regime_fiscal, nouveau texte) +FIXES = [ + ('Compte courant rémunéré', + 'Flat tax 30 %', + 'Flat tax (PFU)'), + ('Compte rémunéré nouvelle génération (fintech)', + 'Flat tax 30 %', + 'Flat tax (PFU)'), + ('Livret bancaire non réglementé', + 'Flat tax 30 % (ou option barème)', + 'Flat tax (PFU) ou option barème'), + ('Compte à terme (CAT)', + 'Flat tax 30 % (ou option barème)', + 'Flat tax (PFU) ou option barème'), + ('Compte-titres ordinaire (CTO)', + "Flat tax 30 % (ou option barème), pas d'avantage fiscal", + "Flat tax (PFU) ou option barème, pas d'avantage fiscal"), + ('Crowdfunding immobilier', + 'Fiscalité classique sur les intérêts (flat tax 30 % ou barème)', + 'Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)'), + ('Compte crypto (exchange)', + "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", + "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"), + ('Wallet crypto (auto-conservation)', + "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", + "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"), + ('Métaux précieux physiques', + "Taxe forfaitaire sur les métaux précieux (11 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", + "Taxe forfaitaire sur les métaux précieux, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat"), + ('Objets de collection', + "Taxe forfaitaire sur les objets d'art/collection/antiquité (6 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", + "Taxe forfaitaire sur les objets d'art/collection/antiquité, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat"), + ('Participation directe non cotée (startups/PME)', + "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", + "Flat tax (PFU) sur les plus-values (ou option barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)"), + ('Wallet crypto (conservation par un tiers)', + "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", + "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"), +] + +# 1) Réécriture du texte source, ligne par ligne (chaque ligne contient "nom: ''," ce qui la +# rend unique même quand deux lignes partagent le même ancien texte regime_fiscal). +lines = content.split("\n") +touched = {nom: False for nom, _, _ in FIXES} +for i, line in enumerate(lines): + for nom, old_txt, new_txt in FIXES: + needle_nom = f"nom: '{nom}'" + if needle_nom in line and old_txt in line and not touched[nom]: + assert line.count(old_txt) == 1, f"{nom}: old_txt found {line.count(old_txt)} times on its line" + lines[i] = line.replace(old_txt, new_txt, 1) + touched[nom] = True + break + +missing = [nom for nom, ok in touched.items() if not ok] +assert not missing, f"lignes non trouvées pour : {missing}" +content = "\n".join(lines) + +# 2) Bloc de backfill toujours exécuté (INSERT OR IGNORE ne corrige pas le texte des lignes déjà +# présentes) : UPDATE ... WHERE nom = ? AND regime_fiscal = '' — ne touche +# jamais une ligne dont regime_fiscal a été modifié manuellement (le texte ne matchera plus). +anchor = "\nexport default db;\n" +assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}" + +backfill_stmts = [] +for nom, old_txt, new_txt in FIXES: + nom_js = nom.replace("\\", "\\\\").replace("'", "\\'") + old_js = old_txt.replace("\\", "\\\\").replace("'", "\\'") + new_js = new_txt.replace("\\", "\\\\").replace("'", "\\'") + backfill_stmts.append( + " db.prepare(\n" + " \"UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') \" +\n" + " \"WHERE nom = ? AND regime_fiscal = ?\"\n" + f" ).run('{new_js}', '{nom_js}', '{old_js}');" + ) + +block = ( + "\n" + "// Correctif du 27/09/26 (demande Olivier : \"Tu alimente la description avec un flat tax à\n" + "// 30%, mais ce n'est plus vrai en 2026... évite de mettre le taux actuel qui peut varier\") —\n" + "// les taux d'imposition précis (flat tax 30 %, taxes forfaitaires 11 %/6 %...) changent dans\n" + "// le temps et rien ici ne les tient à jour : les textes ci-dessus (SEED_ENVELOPPES /\n" + "// EXTRA_ENVELOPPES) ont été réécrits pour décrire le MÉCANISME fiscal (flat tax/PFU, taxe\n" + "// forfaitaire...) sans figer de pourcentage. Comme INSERT OR IGNORE ne corrige pas le texte\n" + "// d'une ligne déjà présente, ce backfill patche les installations déjà seedées — toujours\n" + "// exécuté, guardé par l'ancien texte exact pour ne jamais écraser une modification manuelle\n" + "// faite depuis l'admin (même principe que le backfill des plafonds CEL/PEL).\n" + "{\n" + + "\n".join(backfill_stmts) + "\n" + "}\n" + "\nexport default db;\n" +) + +content = content.replace(anchor, block, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) + +print("Regime fiscal genericization + backfill inserted OK, new length:", len(content)) diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 4819f90..a5de225 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -3669,4 +3669,421 @@ db.exec(` } } + +// ── Chantier "Référentiel Enveloppes & Supports d'épargne" (28/09/26, demande Olivier) ────── +// Catalogue admin des enveloppes fiscales/comptes d'épargne (Livret A, Assurance-vie, PEA, +// PER...) et des supports d'investissement qu'on peut y loger (fonds euros, ETF, SCPI...), +// avec un lien N-N entre les deux référentiels (quels supports sont disponibles dans quelle +// enveloppe) — cadrage validé par Olivier via AskUserQuestion (28/09/26) : +// - Deux référentiels reliés (pas un seul catalogue plat) : enveloppes_referentiel et +// supports_referentiel, liés par enveloppes_supports_disponibles. +// - Catalogue seul pour l'instant, sans rattachement à l'existant (comptes, investissements...) +// — prépare le futur module patrimoine, déjà noté comme "Phase 3" dans le chantier +// Institutions financières (cf. claude/plan_referentiel_institutions_financieres.md). +// - Métadonnées enrichies sur les enveloppes : description, plafond (€) et régime fiscal +// résumé, en plus du nom et des catégories (familles). +// Une enveloppe est transverse à tous les espaces de travail, comme les institutions +// financières (même décision : ce ne sont pas des objets propres à un workspace). +db.exec(` + CREATE TABLE IF NOT EXISTS enveloppes_referentiel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + description TEXT, + plafond_eur REAL, + regime_fiscal 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_envref_nom ON enveloppes_referentiel(nom)'); + +// Catégories (familles) des enveloppes — mêmes 10 regroupements que la liste fournie par +// Olivier (Comptes courants, Épargne réglementée...). Gérées par l'admin, comme +// categories_institutions : table dédiée plutôt que réutiliser categories_inv/categories_institutions, +// une famille d'enveloppe d'épargne n'ayant de sens ni pour une plateforme crowdlending/PE ni +// pour une institution financière. +db.exec(` + CREATE TABLE IF NOT EXISTS categories_enveloppes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec(` + CREATE TABLE IF NOT EXISTS referentiel_categories_enveloppes ( + enveloppe_id INTEGER NOT NULL REFERENCES enveloppes_referentiel(id) ON DELETE CASCADE, + categorie_id INTEGER NOT NULL REFERENCES categories_enveloppes(id) ON DELETE CASCADE, + PRIMARY KEY (enveloppe_id, categorie_id) + ) +`); + +// Référentiel des supports d'investissement (fonds euros, ETF, SCPI...) — catalogue à plat, +// pas de sous-catégories (une dizaine d'entrées seulement, cf. "Types de supports (UC) +// disponibles" dans la liste d'Olivier). +db.exec(` + CREATE TABLE IF NOT EXISTS supports_referentiel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + description 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_suppref_nom ON supports_referentiel(nom)'); + +// Lien N-N : quels supports sont disponibles dans quelle enveloppe (ex. Assurance-vie +// multisupport ↔ fonds euros, ETF, SCPI...). Une enveloppe sans aucune ligne ici (Livret A, +// PEL, Compte courant...) est une enveloppe "cash" : pas de choix de support, juste un solde. +db.exec(` + CREATE TABLE IF NOT EXISTS enveloppes_supports_disponibles ( + enveloppe_id INTEGER NOT NULL REFERENCES enveloppes_referentiel(id) ON DELETE CASCADE, + support_id INTEGER NOT NULL REFERENCES supports_referentiel(id) ON DELETE CASCADE, + PRIMARY KEY (enveloppe_id, support_id) + ) +`); + +// Seed des catégories (familles) — idempotent, n'insère que si la table est vide (l'admin +// reste libre de renommer/supprimer ensuite depuis l'écran dédié, comme pour les institutions). +{ + const catEnvCount = db.prepare('SELECT COUNT(*) AS n FROM categories_enveloppes').get().n; + if (catEnvCount === 0) { + const insCatEnv = db.prepare('INSERT OR IGNORE INTO categories_enveloppes (nom) VALUES (?)'); + db.transaction(() => { + for (const nom of [ + 'Comptes courants', + 'Épargne réglementée', + 'Épargne bancaire non réglementée', + 'Assurance-vie', + 'Épargne retraite', + 'Épargne salariale', + 'Enveloppes titres / marchés financiers', + 'Immobilier papier', + 'Financement participatif / private equity', + 'Épargne solidaire', + ]) insCatEnv.run(nom); + })(); + } +} + +// Seed des supports d'investissement (idempotent). +{ + const suppRefCount = db.prepare('SELECT COUNT(*) AS n FROM supports_referentiel').get().n; + if (suppRefCount === 0) { + const SEED_SUPPORTS = [ + { nom: 'Fonds en euros', description: "Fonds en euros classique ou nouvelle génération (eurocroissance) — capital garanti, ou partiellement pour l'eurocroissance." }, + { nom: 'OPCVM / fonds actifs', description: 'Fonds gérés activement — actions, obligations, diversifiés...' }, + { nom: 'ETF / trackers', description: 'Fonds indiciels cotés répliquant un indice.' }, + { nom: 'Titres vifs (actions)', description: 'Actions détenues en direct.' }, + { nom: 'Obligations / fonds datés', description: 'Obligations en direct ou fonds obligataires à échéance.' }, + { nom: 'Immobilier papier (SCPI/SCI/OPCI)', description: "Parts de SCPI, SCI ou OPCI logées dans une enveloppe, par opposition à la détention en direct (cf. enveloppes SCPI/OPCI/SCI dédiées)." }, + { nom: 'Private equity (FCPR)', description: 'Fonds de capital-investissement/capital-risque, logeable en assurance-vie ou PER.' }, + { nom: 'Produits structurés', description: "Produits à formule, profil rendement/risque défini à l'avance." }, + { nom: 'Matières premières / métaux précieux', description: 'Or et autres matières premières, via trackers ou supports dédiés.' }, + { nom: 'Fonds solidaires (90/10)', description: "Fonds investissant 5 à 10 % dans l'économie solidaire — souvent logés en PEE ou assurance-vie." }, + ]; + const insSupp = db.prepare('INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)'); + db.transaction(() => { for (const s of SEED_SUPPORTS) insSupp.run(s.nom, s.description); })(); + } +} + +// Seed des enveloppes + liens catégories + liens supports disponibles (idempotent) — +// transcription de la liste fournie par Olivier le 28/09/26, dédupliquée (le PERCOL n'est +// listé qu'une fois mais porte les deux catégories Épargne retraite + Épargne salariale ; le +// FCPR et l'immobilier "papier" logé en enveloppe existent uniquement comme SUPPORT quand ils +// sont hébergés dans une assurance-vie/PER, et comme ENVELOPPE à part quand ils sont détenus en +// direct). La liste enveloppe→supports est une proposition par défaut, éditable ensuite depuis +// l'écran admin (cases à cocher) — elle ne prétend pas capturer l'offre exacte de chaque contrat. +{ + const envRefCount = db.prepare('SELECT COUNT(*) AS n FROM enveloppes_referentiel').get().n; + if (envRefCount === 0) { + const AV_PER_SUPPORTS = [ + 'Fonds en euros', 'OPCVM / fonds actifs', 'ETF / trackers', 'Titres vifs (actions)', + 'Obligations / fonds datés', 'Immobilier papier (SCPI/SCI/OPCI)', 'Private equity (FCPR)', + 'Produits structurés', 'Matières premières / métaux précieux', + 'Fonds solidaires (90/10)', + ]; + const SEED_ENVELOPPES = [ + // 0. Comptes courants + { nom: 'Compte courant non rémunéré', categories: ['Comptes courants'], description: 'La norme en France — disponibilité totale, aucun risque, aucun rendement.', plafond_eur: null, regime_fiscal: null, supports: [] }, + { nom: 'Compte courant rémunéré', categories: ['Comptes courants'], description: 'Proposé par certaines banques en ligne ou offres promotionnelles, taux faible.', plafond_eur: null, regime_fiscal: 'Flat tax (PFU)', supports: [] }, + { nom: 'Compte rémunéré nouvelle génération (fintech)', categories: ['Comptes courants'], description: "« Super livret » nouvelle génération — néobanques/fintechs (Cashbee, Ramify Rendement...), souvent sans plafond, liquidité immédiate.", plafond_eur: null, regime_fiscal: 'Flat tax (PFU)', supports: [] }, + // 1. Épargne réglementée + { nom: 'Livret A', categories: ['Épargne réglementée'], description: null, plafond_eur: 22950, regime_fiscal: 'Exonéré (IR et prélèvements sociaux)', supports: [] }, + { nom: 'LDDS', categories: ['Épargne réglementée'], description: 'Livret de Développement Durable et Solidaire.', plafond_eur: 12000, regime_fiscal: 'Exonéré (IR et prélèvements sociaux)', supports: [] }, + { nom: 'LEP', categories: ['Épargne réglementée'], description: "Livret d'Épargne Populaire — sous conditions de revenus, taux généralement plus élevé.", plafond_eur: 10000, regime_fiscal: 'Exonéré (IR et prélèvements sociaux)', supports: [] }, + { nom: 'Livret Jeune', categories: ['Épargne réglementée'], description: '12-25 ans.', plafond_eur: 1600, regime_fiscal: 'Exonéré (IR et prélèvements sociaux)', supports: [] }, + { nom: 'CEL', categories: ['Épargne réglementée'], description: 'Compte Épargne Logement — droits à prêt immobilier.', plafond_eur: 15300, regime_fiscal: null, supports: [] }, + { nom: 'PEL', categories: ['Épargne réglementée'], description: 'Plan Épargne Logement — droits à prêt immobilier.', plafond_eur: 61200, regime_fiscal: null, supports: [] }, + // 2. Épargne bancaire non réglementée + { nom: 'Livret bancaire non réglementé', categories: ['Épargne bancaire non réglementée'], description: 'Livret « boosté », taux libre.', plafond_eur: null, regime_fiscal: 'Flat tax (PFU) ou option barème', supports: [] }, + { nom: 'Compte à terme (CAT)', categories: ['Épargne bancaire non réglementée'], description: 'Taux garanti sur une durée fixe.', plafond_eur: null, regime_fiscal: 'Flat tax (PFU) ou option barème', supports: [] }, + // 3. Assurance-vie + { nom: 'Assurance-vie monosupport (fonds euros)', categories: ['Assurance-vie'], description: 'Capital garanti.', plafond_eur: null, regime_fiscal: 'Avantageux après 8 ans (abattement annuel + PFU réduit), cadre privilégié pour la transmission', supports: ['Fonds en euros'] }, + { nom: 'Assurance-vie multisupport', categories: ['Assurance-vie'], description: 'Fonds euros + unités de compte.', plafond_eur: null, regime_fiscal: 'Avantageux après 8 ans (abattement annuel + PFU réduit), cadre privilégié pour la transmission', supports: AV_PER_SUPPORTS }, + // 4. Épargne retraite + { nom: 'PER individuel', categories: ['Épargne retraite'], description: 'Ex-PERP, Madelin — sortie en capital ou rente.', plafond_eur: null, regime_fiscal: "Déduction fiscale à l'entrée, sortie imposée (capital ou rente)", supports: AV_PER_SUPPORTS }, + { nom: 'PER collectif / obligatoire (PERCOL, article 83)', categories: ['Épargne retraite', 'Épargne salariale'], description: "Via l'entreprise.", plafond_eur: null, regime_fiscal: "Déduction fiscale à l'entrée, sortie imposée (capital ou rente)", supports: AV_PER_SUPPORTS }, + // 5. Épargne salariale + { nom: 'PEE', categories: ['Épargne salariale'], description: "Plan d'Épargne Entreprise — abondement possible, blocage 5 ans.", plafond_eur: null, regime_fiscal: "Exonéré d'IR sur les plus-values si blocage 5 ans respecté (hors prélèvements sociaux)", supports: ['OPCVM / fonds actifs', 'ETF / trackers', 'Titres vifs (actions)', 'Fonds solidaires (90/10)'] }, + // 6. Enveloppes titres / marchés financiers + { nom: 'PEA', categories: ['Enveloppes titres / marchés financiers'], description: 'Actions européennes.', plafond_eur: 150000, regime_fiscal: "Exonéré d'IR après 5 ans (hors prélèvements sociaux)", supports: ['Titres vifs (actions)', 'ETF / trackers'] }, + { nom: 'PEA-PME', categories: ['Enveloppes titres / marchés financiers'], description: 'Dédié aux PME/ETI, cumulable avec le PEA.', plafond_eur: 225000, regime_fiscal: "Exonéré d'IR après 5 ans (hors prélèvements sociaux)", supports: ['Titres vifs (actions)', 'ETF / trackers'] }, + { nom: 'Compte-titres ordinaire (CTO)', categories: ['Enveloppes titres / marchés financiers'], description: "Sans plafond, sans avantage fiscal, tous types d'actifs.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) ou option barème, pas d'avantage fiscal", supports: ['Titres vifs (actions)', 'ETF / trackers', 'Obligations / fonds datés', 'Produits structurés', 'Matières premières / métaux précieux', 'OPCVM / fonds actifs'] }, + // 7. Immobilier papier + { nom: 'SCPI (détenue en direct ou à crédit)', categories: ['Immobilier papier'], description: 'Peut aussi être logée en assurance-vie/PER — cf. le support « Immobilier papier ».', plafond_eur: null, regime_fiscal: 'Revenus fonciers, fiscalité classique', supports: [] }, + { nom: 'OPCI', categories: ['Immobilier papier'], description: 'Plus liquide que la SCPI — part immobilière + actifs financiers.', plafond_eur: null, regime_fiscal: 'Fiscalité classique selon la poche (foncière/financière)', supports: [] }, + { nom: 'SCI', categories: ['Immobilier papier'], description: 'Support immobilier collectif, souvent logé en assurance-vie.', plafond_eur: null, regime_fiscal: "Fiscalité classique (ou celle de l'assurance-vie si logée dedans)", supports: [] }, + // 8. Financement participatif / private equity + { nom: 'Crowdfunding immobilier', categories: ['Financement participatif / private equity'], description: 'Hors enveloppe fiscale dédiée.', plafond_eur: null, regime_fiscal: 'Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)', supports: [] }, + { nom: 'Crowdequity', categories: ['Financement participatif / private equity'], description: 'Prise de participation dans des entreprises, éligible parfois au PEA-PME.', plafond_eur: null, regime_fiscal: 'Fiscalité classique sur les plus-values (sauf logement en PEA-PME)', supports: [] }, + { nom: 'FCPI / FIP', categories: ['Financement participatif / private equity'], description: 'Risque élevé, blocage long.', plafond_eur: null, regime_fiscal: "Réduction d'IR à l'entrée", supports: [] }, + // 9. Épargne solidaire + { nom: 'Livret solidaire', categories: ['Épargne solidaire'], description: null, plafond_eur: null, regime_fiscal: 'Selon le support (réglementé ou non)', supports: [] }, + ]; + const insEnvRef = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)'); + const getEnvRefId = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const getCatEnvId = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?'); + const linkEnvCat = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)'); + const getSuppRefId = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?'); + const linkEnvSupp = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)'); + db.transaction(() => { + for (const { nom, description, plafond_eur, regime_fiscal, categories, supports } of SEED_ENVELOPPES) { + insEnvRef.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null); + const env = getEnvRefId.get(nom); + if (!env) continue; + for (const catNom of categories) { + const cat = getCatEnvId.get(catNom); + if (cat) linkEnvCat.run(env.id, cat.id); + } + for (const suppNom of supports) { + const supp = getSuppRefId.get(suppNom); + if (supp) linkEnvSupp.run(env.id, supp.id); + } + } + })(); + } +} + +// Backfill idempotent (29/09/26, demande Olivier) : plafonds réglementaires CEL/PEL, absents du +// cadrage initial (non communiqués par Olivier, donc laissés à null pour ne rien inventer). +// Toujours exécuté (pas de garde COUNT()===0) mais ne touche que plafond_eur IS NULL, pour ne +// jamais écraser une valeur déjà éditée manuellement depuis l'écran admin. +db.prepare(` + UPDATE enveloppes_referentiel SET plafond_eur = 15300, updated_at = datetime('now') + WHERE nom = 'CEL' AND plafond_eur IS NULL +`).run(); +db.prepare(` + UPDATE enveloppes_referentiel SET plafond_eur = 61200, updated_at = datetime('now') + WHERE nom = 'PEL' AND plafond_eur IS NULL +`).run(); + +// Extension du 29/09/26 (demande Olivier) : "je ne voudrais pas faire un nième référentiel [...] +// il faut peut-être que cela soit des catégories supplémentaires même si ce n'a pas tout à fait +// le même statut" — plutôt qu'un 3e référentiel séparé pour les actifs "physiques"/hors +// enveloppe fiscale (immobilier direct, crypto, métaux précieux, objets de collection, +// participations non cotées), ils sont intégrés au référentiel existant : quelques nouvelles +// catégories (familles) dans categories_enveloppes, et des enveloppes rattachées, sur le même +// principe que les enveloppes SCPI/OPCI/SCI détenues en direct déjà présentes (aucun support — +// détention directe, pas de menu de supports ; aucun institution_id — catalogue seul, comme le +// reste de ce référentiel). Les participations non cotées réutilisent la catégorie existante +// "Financement participatif / private equity" plutôt que d'en créer une nouvelle (même famille +// que le Crowdequity et les FCPI/FIP). Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom) +// — s'ajoute sans écraser à un référentiel déjà seedé, comme le backfill CEL/PEL ci-dessus. +{ + const insCatEnvExtra = db.prepare('INSERT OR IGNORE INTO categories_enveloppes (nom) VALUES (?)'); + for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection', 'Investissement forestier & viticole']) { + insCatEnvExtra.run(nom); + } + + const AV_PER_SUPPORTS_EXTRA = [ + 'Fonds en euros', 'OPCVM / fonds actifs', 'ETF / trackers', 'Titres vifs (actions)', + 'Obligations / fonds datés', 'Immobilier papier (SCPI/SCI/OPCI)', 'Private equity (FCPR)', + 'Produits structurés', 'Matières premières / métaux précieux', + 'Fonds solidaires (90/10)', + ]; + + const EXTRA_ENVELOPPES = [ + { nom: 'Résidence principale', categories: ['Immobilier physique'], description: "Logement occupé à titre de résidence principale.", plafond_eur: null, regime_fiscal: "Plus-value de cession exonérée (résidence principale)", supports: [] }, + { nom: 'Résidence secondaire', categories: ['Immobilier physique'], description: "Logement occupé à titre de résidence secondaire (non loué).", plafond_eur: null, regime_fiscal: "Plus-value de cession imposable (abattement selon durée de détention, exonération totale après 22 ans IR / 30 ans prélèvements sociaux)", supports: [] }, + { nom: 'Immobilier locatif (détention directe)', categories: ['Immobilier physique'], description: "Bien immobilier loué, détenu en direct (hors SCPI/SCI).", plafond_eur: null, regime_fiscal: "Revenus fonciers (micro-foncier ou réel) + plus-value immobilière (abattement selon durée de détention)", supports: [] }, + { nom: 'Compte crypto (exchange)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues sur une plateforme d'échange centralisée.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + { nom: 'Wallet crypto (auto-conservation)', categories: ['Actifs numériques'], description: "Cryptomonnaies en auto-conservation (wallet matériel ou logiciel).", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + { nom: 'Métaux précieux physiques', categories: ['Métaux précieux'], description: "Or, argent... détenus physiquement (lingots, pièces).", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les métaux précieux, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] }, + { nom: 'Objets de collection', categories: ['Objets de collection'], description: "Montres, art, antiquités...", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les objets d'art/collection/antiquité, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] }, + { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values (ou option barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] }, + { nom: 'Contrat de capitalisation', categories: ['Assurance-vie'], description: "Support d'épargne proche de l'assurance-vie (fonds euros + unités de compte), mais sans clause bénéficiaire ni dénouement au décès — entre dans la succession classique. Peut être souscrit par une personne physique ou une personne morale (société, SCI...).", plafond_eur: null, regime_fiscal: "Fiscalité des gains identique à l'assurance-vie pour un particulier (abattement après 8 ans, PFU ou PFL) ; pas de régime de succession spécifique (articles 990 I / 757 B) ; transmissible par donation du vivant, contrairement à l'assurance-vie", supports: AV_PER_SUPPORTS_EXTRA }, + { nom: 'Assurance-vie luxembourgeoise', categories: ['Assurance-vie'], description: "Contrat d'assurance-vie souscrit auprès d'un assureur luxembourgeois — triangle de sécurité (actifs ségrégués auprès d'une banque dépositaire distincte de l'assureur), univers d'investissement et devises généralement plus larges qu'un contrat français.", plafond_eur: null, regime_fiscal: "Fiscalité identique à l'assurance-vie française pour un résident fiscal français (abattement après 8 ans, PFU/PFL) ; déclaration du contrat étranger requise (formulaire 3916-3916 bis)", supports: AV_PER_SUPPORTS_EXTRA }, + { nom: 'Girardin (industriel / social)', categories: ['Financement participatif / private equity'], description: "Investissement dans un dispositif de défiscalisation outre-mer — financement d'un investissement productif ou de logement social en DOM-TOM, en échange d'une réduction d'impôt immédiate. Capital non récupéré (pas de valeur résiduelle), contrairement à un FCPI/FIP.", plafond_eur: null, regime_fiscal: "Réduction d'IR l'année suivant l'investissement, généralement supérieure au montant investi (effet de levier) ; plafonnement spécifique des niches fiscales outre-mer ; risque de remise en cause fiscale si le monteur/exploitant ne respecte pas ses engagements (durée de conservation 5 ans)", supports: [] }, + { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] }, + { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] }, + { nom: 'Dette privée (fonds)', categories: ['Financement participatif / private equity'], description: "Fonds de dette privée (private debt) — prêts directs à des entreprises via un véhicule de fonds professionnel, hors plateforme de crowdlending grand public.", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] }, + { nom: 'Capital-investissement réservé (FPCI / SLP / FPS)', categories: ['Financement participatif / private equity'], description: "Fonds de capital-investissement réservés aux investisseurs avertis/professionnels — Fonds Professionnel de Capital Investissement (FPCI), Société de Libre Partenariat (SLP), Fonds Professionnel Spécialisé (FPS) ; distinct du FCPR grand public (support disponible en assurance-vie/PER/contrat de capitalisation) et de la participation directe non cotée (prise de participation en direct, hors fonds).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les plus-values de cession de parts (flat tax / PFU, ou option barème) ; exonération possible sous conditions de durée de détention, comme pour un FCPR", supports: [] }, + { nom: 'Infrastructures non cotées', categories: ['Financement participatif / private equity'], description: "Fonds d'investissement dans des actifs d'infrastructure non cotés (énergie, transport, télécoms...).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] }, + { nom: 'Groupement Foncier Forestier (GFF)', categories: ['Investissement forestier & viticole'], description: "Investissement forestier via un groupement foncier forestier — parts donnant une quote-part de bois et forêts en gestion collective.", plafond_eur: null, regime_fiscal: "Réduction d'IR à l'entrée possible sous conditions (dispositif DEFI-Forêt), exonération partielle d'IFI, transmission facilitée sous conditions d'engagement de gestion durable", supports: [] }, + { nom: 'Groupement Foncier Viticole (GFV)', categories: ['Investissement forestier & viticole'], description: "Investissement viticole via un groupement foncier viticole — parts donnant une quote-part de vignes louées à un exploitant via un bail rural.", plafond_eur: null, regime_fiscal: "Revenus fonciers (loyers du bail rural), exonération partielle d'IFI et de droits de succession/donation sous conditions", supports: [] }, + { nom: 'Immobilier locatif meublé (LMNP/LMP)', categories: ['Immobilier physique'], description: "Location meublée en direct — statut Loueur en Meublé Non Professionnel (LMNP) ou Professionnel (LMP) selon les seuils de recettes et la composition du foyer fiscal ; distinct de « Immobilier locatif (détention directe) » qui suppose une location nue (revenus fonciers).", plafond_eur: null, regime_fiscal: "Revenus imposés en BIC (micro-BIC ou régime réel avec amortissement du bien) ; le statut LMP ouvre en plus à l'imputation des déficits sur le revenu global et à un régime de plus-value professionnelle", supports: [] }, + { nom: 'SCPI en démembrement (nue-propriété / usufruit temporaire)', categories: ['Immobilier papier'], description: "SCPI achetée en démembrement de propriété (nue-propriété ou usufruit temporaire), typiquement sur 5 à 10 ans.", plafond_eur: null, regime_fiscal: "Usufruitier : perçoit les loyers et est imposé en revenus fonciers pendant la durée du démembrement. Nu-propriétaire : aucun revenu ni imposition pendant la période, récupère la pleine propriété sans fiscalité à l'extinction ; non redevable de l'IFI sur la valeur du bien démembré pendant la durée (sauf exceptions)", supports: [] }, + ]; + const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)'); + const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?'); + const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)'); + const getSuppRefIdExtra = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?'); + const linkEnvSuppExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)'); + db.transaction(() => { + for (const { nom, description, plafond_eur, regime_fiscal, categories, supports } of EXTRA_ENVELOPPES) { + insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null); + const env = getEnvRefIdExtra.get(nom); + if (!env) continue; + for (const catNom of categories) { + const cat = getCatEnvIdExtra.get(catNom); + if (cat) linkEnvCatExtra.run(env.id, cat.id); + } + for (const suppNom of (supports || [])) { + const supp = getSuppRefIdExtra.get(suppNom); + if (supp) linkEnvSuppExtra.run(env.id, supp.id); + } + } + })(); +} + +// Extension du 29/09/26 (demande Olivier, à propos de produits Spiko/Regular Finance) : "sais-tu +// comment les classifier ?" — Spiko et Regular Finance ne sont pas des enveloppes mais des +// PLATEFORMES/INSTITUTIONS donnant accès à un fonds monétaire (OPCVM monétaire, y compris +// lorsqu'il est tokenisé sur blockchain comme chez Spiko) : elles ont vocation à rejoindre +// institutions_referentiel (catégorie à créer, ex. "Gestion de trésorerie"), pas +// enveloppes_referentiel — le rattachement viendra en Phase 3. Ce qui manquait réellement à CE +// référentiel, c'est le SUPPORT lui-même : "OPCVM / fonds actifs" existant désigne des fonds +// actions/obligations/diversifiés gérés activement, pas un fonds monétaire (quasi-cash, duration +// très courte, profil de risque très différent) — les deux ne doivent pas être confondus. +// Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom), même principe que les extensions +// précédentes. +{ + db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run( + 'Fonds monétaires (OPCVM monétaire)', + "Fonds monétaire classique ou tokenisé sur blockchain (ex. Spiko, Regular Finance) — quasi-cash, duration très courte, distinct d'un OPCVM actions/obligations/diversifié." + ); + const getSuppRefIdMonetaire = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?'); + const suppMonetaire = getSuppRefIdMonetaire.get('Fonds monétaires (OPCVM monétaire)'); + if (suppMonetaire) { + const getEnvRefIdMonetaire = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const linkEnvSuppMonetaire = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)'); + // Enveloppes où un fonds monétaire a sa place (menu de supports déjà large, cohérent avec un + // placement défensif) — PEA/PEA-PME exclus (non éligibles réglementairement), enveloppes + // "cash" (Livret A, comptes courants...) exclues (déjà sans menu de supports par nature). + for (const envNom of [ + 'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel', + 'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation', + 'Assurance-vie luxembourgeoise', + ]) { + const env = getEnvRefIdMonetaire.get(envNom); + if (env) linkEnvSuppMonetaire.run(env.id, suppMonetaire.id); + } + } +} + +// Correctif du 27/09/26 (demande Olivier : "Tu alimente la description avec un flat tax à +// 30%, mais ce n'est plus vrai en 2026... évite de mettre le taux actuel qui peut varier") — +// les taux d'imposition précis (flat tax 30 %, taxes forfaitaires 11 %/6 %...) changent dans +// le temps et rien ici ne les tient à jour : les textes ci-dessus (SEED_ENVELOPPES / +// EXTRA_ENVELOPPES) ont été réécrits pour décrire le MÉCANISME fiscal (flat tax/PFU, taxe +// forfaitaire...) sans figer de pourcentage. Comme INSERT OR IGNORE ne corrige pas le texte +// d'une ligne déjà présente, ce backfill patche les installations déjà seedées — toujours +// exécuté, guardé par l'ancien texte exact pour ne jamais écraser une modification manuelle +// faite depuis l'admin (même principe que le backfill des plafonds CEL/PEL). +{ + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU)', 'Compte courant rémunéré', 'Flat tax 30 %'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU)', 'Compte rémunéré nouvelle génération (fintech)', 'Flat tax 30 %'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU) ou option barème', 'Livret bancaire non réglementé', 'Flat tax 30 % (ou option barème)'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU) ou option barème', 'Compte à terme (CAT)', 'Flat tax 30 % (ou option barème)'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU) ou option barème, pas d\'avantage fiscal', 'Compte-titres ordinaire (CTO)', 'Flat tax 30 % (ou option barème), pas d\'avantage fiscal'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)', 'Crowdfunding immobilier', 'Fiscalité classique sur les intérêts (flat tax 30 % ou barème)'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086', 'Compte crypto (exchange)', 'Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086', 'Wallet crypto (auto-conservation)', 'Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Taxe forfaitaire sur les métaux précieux, ou option pour le régime des plus-values sur biens meubles si justificatifs d\'achat', 'Métaux précieux physiques', 'Taxe forfaitaire sur les métaux précieux (11 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d\'achat'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Taxe forfaitaire sur les objets d\'art/collection/antiquité, ou option pour le régime des plus-values sur biens meubles si justificatifs d\'achat', 'Objets de collection', 'Taxe forfaitaire sur les objets d\'art/collection/antiquité (6 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d\'achat'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU) sur les plus-values (ou option barème) ; réduction d\'IR à l\'entrée possible sous conditions (IR-PME/Madelin)', 'Participation directe non cotée (startups/PME)', 'Flat tax 30 % sur les plus-values (ou barème) ; réduction d\'IR à l\'entrée possible sous conditions (IR-PME/Madelin)'); + db.prepare( + "UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') " + + "WHERE nom = ? AND regime_fiscal = ?" + ).run('Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086', 'Wallet crypto (conservation par un tiers)', 'Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086'); +} + +// Extension du 29/09/26 (suite) — support "ETC" (demande Olivier, à partir d'une capture d'écran +// d'un contrat d'assurance-vie distinguant "+200 ETF & 22 ETC" parmi ses supports) : un Exchange +// Traded Commodity (ETC) est un instrument coté qui réplique le cours d'une matière première +// (or, pétrole...), proche d'un ETF dans sa mécanique de cotation mais sans statut UCITS/OPCVM — +// distinct du support "ETF / trackers" (fonds indiciels UCITS) et de "Matières premières / métaux +// précieux" (autre modalité d'exposition aux matières premières, ex. en unités de compte +// d'assurance-vie). Rattaché aux mêmes enveloppes que "Fonds monétaires" ci-dessus (menu de +// supports déjà large) ; PEA/PEA-PME exclus, comme en pratique un ETC matières premières +// physiques n'est pas éligible au PEA. Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom). +{ + db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run( + 'ETC (Exchange Traded Commodities)', + "Tracker coté sur une matière première (or, pétrole, panier de matières premières...) — proche de l'ETF dans sa mécanique de cotation, mais sans statut UCITS/OPCVM ; distinct du support « ETF / trackers » et de « Matières premières / métaux précieux »." + ); + const getSuppRefIdEtc = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?'); + const suppEtc = getSuppRefIdEtc.get('ETC (Exchange Traded Commodities)'); + if (suppEtc) { + const getEnvRefIdEtc = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?'); + const linkEnvSuppEtc = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)'); + for (const envNom of [ + 'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel', + 'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation', + 'Assurance-vie luxembourgeoise', + ]) { + const env = getEnvRefIdEtc.get(envNom); + if (env) linkEnvSuppEtc.run(env.id, suppEtc.id); + } + } +} + +// Correctif du 29/09/26 (suite) — retrait du support "Gestion pilotée / mandat" (demande +// Olivier, à propos d'une capture d'écran distinguant "3 modes de gestion" — libre / pilotée / +// mixte — d'un contrat d'assurance-vie) : le "mode de gestion" décrit QUI décide de l'allocation +// (le titulaire, ou un gérant délégué selon un mandat), c'est une dimension orthogonale au +// SUPPORT (le type d'instrument financier détenu — ETF, actions, SCPI...). Une gestion pilotée +// s'applique justement à un panier de vrais supports (fonds euros, fonds datés, SCPI, produits +// structurés...), elle n'en est pas un elle-même. Cette ligne datait du seed initial du 28/09/26 +// et n'aurait pas dû être modélisée comme support ; elle est retirée du référentiel et de la +// liste de supports "classiques" AV/PER (voir plus haut). Toujours exécuté et idempotent — +// DELETE cascade automatiquement les liens vers cette entrée (enveloppes_supports_disponibles, +// ON DELETE CASCADE) ; aucune donnée d'investissement réelle n'est rattachée à ce catalogue à ce +// stade, la suppression est donc sans risque de perte de données utilisateur. +db.prepare("DELETE FROM supports_referentiel WHERE nom = 'Gestion pilotée / mandat'").run(); + export default db; diff --git a/backend/src/routes/enveloppesReferentiel.js b/backend/src/routes/enveloppesReferentiel.js new file mode 100644 index 0000000..73d78c0 --- /dev/null +++ b/backend/src/routes/enveloppesReferentiel.js @@ -0,0 +1,174 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +/** + * routes/enveloppesReferentiel.js — Référentiel admin des enveloppes d'épargne/investissement + * (Livret A, Assurance-vie, PEA, PER...) — chantier "Référentiel Enveloppes & Supports + * d'épargne" (28/09/26, demande Olivier). + * + * Même esprit que institutionsReferentiel.js : catalogue transverse à tous les espaces de + * travail, catégories (familles) en N-N via referentiel_categories_enveloppes. S'y ajoute une + * seconde relation N-N propre à ce référentiel : enveloppes_supports_disponibles, qui indique + * quels supports d'investissement (supports_referentiel — fonds euros, ETF, SCPI...) sont + * disponibles dans chaque enveloppe (ex. Assurance-vie multisupport ↔ fonds euros, ETF, SCPI...). + * Une enveloppe "cash" (Livret A, Compte courant...) n'a simplement aucune ligne dans cette + * seconde relation. + * + * Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion le 28/09/26) — + * aucun rattachement à l'existant (comptes, investissements...), en préparation du futur module + * patrimoine (cf. plan de déploiement, doc projet). + */ + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +const Schema = z.object({ + nom: z.string().min(1).max(200), + description: z.string().nullable().optional(), + plafond_eur: z.number().positive().nullable().optional(), + regime_fiscal: z.string().nullable().optional(), + categories_ids: z.array(z.number().int()).optional().default([]), + supports_ids: z.array(z.number().int()).optional().default([]), +}); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Catégories (familles) attachées à chaque enveloppe */ +function attachCategories(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const links = db.prepare(` + SELECT rc.enveloppe_id, c.id AS categorie_id, c.nom + FROM referentiel_categories_enveloppes rc + JOIN categories_enveloppes c ON c.id = rc.categorie_id + WHERE rc.enveloppe_id IN (${ids.map(() => '?').join(',')}) + ORDER BY c.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.enveloppe_id]) map[l.enveloppe_id] = []; + map[l.enveloppe_id].push({ id: l.categorie_id, nom: l.nom }); + } + return rows.map(r => ({ ...r, categories: map[r.id] || [] })); +} + +/** Supports disponibles attachés à chaque enveloppe */ +function attachSupports(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const links = db.prepare(` + SELECT esd.enveloppe_id, s.id AS support_id, s.nom + FROM enveloppes_supports_disponibles esd + JOIN supports_referentiel s ON s.id = esd.support_id + WHERE esd.enveloppe_id IN (${ids.map(() => '?').join(',')}) + ORDER BY s.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.enveloppe_id]) map[l.enveloppe_id] = []; + map[l.enveloppe_id].push({ id: l.support_id, nom: l.nom }); + } + return rows.map(r => ({ ...r, supports: map[r.id] || [] })); +} + +function saveCategories(enveloppeId, categoriesIds) { + db.prepare('DELETE FROM referentiel_categories_enveloppes WHERE enveloppe_id = ?').run(enveloppeId); + if (categoriesIds && categoriesIds.length > 0) { + const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?,?)'); + for (const id of categoriesIds) ins.run(enveloppeId, id); + } +} + +function saveSupports(enveloppeId, supportsIds) { + db.prepare('DELETE FROM enveloppes_supports_disponibles WHERE enveloppe_id = ?').run(enveloppeId); + if (supportsIds && supportsIds.length > 0) { + const ins = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?,?)'); + for (const id of supportsIds) ins.run(enveloppeId, id); + } +} + +// ── GET /api/enveloppes-referentiel ───────────────────────────────────────── +router.get('/', (_req, res) => { + let rows = db.prepare('SELECT * FROM enveloppes_referentiel ORDER BY nom').all(); + rows = attachSupports(attachCategories(rows)); + res.json(rows); +}); + +// ── GET /api/enveloppes-referentiel/:id ───────────────────────────────────── +router.get('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Enveloppe introuvable'); + const [enriched] = attachSupports(attachCategories([row])); + res.json(enriched); + } catch (e) { next(e); } +}); + +// ── POST /api/enveloppes-referentiel ──────────────────────────────────────── +router.post('/', (req, res, next) => { + try { + const data = Schema.parse(req.body); + const existing = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?').get(data.nom); + if (existing) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`); + + const r = db.prepare(` + INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, updated_at) + VALUES (?,?,?,?, datetime('now')) + `).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null); + + const enveloppeId = r.lastInsertRowid; + db.transaction(() => { + saveCategories(enveloppeId, data.categories_ids); + saveSupports(enveloppeId, data.supports_ids); + })(); + + const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(enveloppeId); + const [enriched] = attachSupports(attachCategories([row])); + res.status(201).json(enriched); + } catch (e) { next(e); } +}); + +// ── PUT /api/enveloppes-referentiel/:id ───────────────────────────────────── +router.put('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Enveloppe introuvable'); + + const data = Schema.parse(req.body); + if (data.nom !== row.nom) { + const dup = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id); + if (dup) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`); + } + + db.prepare(` + UPDATE enveloppes_referentiel + SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, updated_at=datetime('now') + WHERE id=? + `).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, row.id); + + db.transaction(() => { + saveCategories(row.id, data.categories_ids); + saveSupports(row.id, data.supports_ids); + })(); + + const updated = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(row.id); + const [enriched] = attachSupports(attachCategories([updated])); + res.json(enriched); + } catch (e) { next(e); } +}); + +// ── DELETE /api/enveloppes-referentiel/:id ────────────────────────────────── +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM enveloppes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Enveloppe introuvable'); + // referentiel_categories_enveloppes et enveloppes_supports_disponibles ont toutes deux + // ON DELETE CASCADE sur enveloppe_id : aucune purge manuelle nécessaire. + db.prepare('DELETE FROM enveloppes_referentiel WHERE id = ?').run(row.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/ref-categories-enveloppes.js b/backend/src/routes/ref-categories-enveloppes.js new file mode 100644 index 0000000..3a0a3b5 --- /dev/null +++ b/backend/src/routes/ref-categories-enveloppes.js @@ -0,0 +1,104 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +/** + * routes/ref-categories-enveloppes.js — liste globale des catégories (familles) du référentiel + * "Enveloppes & Supports d'épargne" (Comptes courants, Épargne réglementée, Assurance-vie...). + * Même patron que routes/ref-categories-institutions.js (categories_institutions), table dédiée + * (categories_enveloppes) — cf. enveloppesReferentiel.js. + */ + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +/* GET /api/ref-categories-enveloppes — liste complète */ +router.get('/', (_req, res) => { + const rows = db.prepare(` + SELECT c.id, c.nom, + COUNT(rc.enveloppe_id) AS nb_utilises + FROM categories_enveloppes c + LEFT JOIN referentiel_categories_enveloppes rc ON rc.categorie_id = c.id + GROUP BY c.id + ORDER BY c.nom + `).all(); + res.json(rows); +}); + +/* POST /api/ref-categories-enveloppes — créer */ +router.post('/', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const exists = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?').get(nom.trim()); + if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + const r = db.prepare('INSERT INTO categories_enveloppes (nom) VALUES (?)').run(nom.trim()); + res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 }); + } catch (e) { next(e); } +}); + +/* PUT /api/ref-categories-enveloppes/:id — renommer (propage automatiquement) */ +router.put('/:id', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const row = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + const dup = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ? AND id != ?').get(nom.trim(), row.id); + if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + db.prepare('UPDATE categories_enveloppes SET nom = ? WHERE id = ?').run(nom.trim(), row.id); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_enveloppes WHERE categorie_id = ?').get(row.id).n; + res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb }); + } catch (e) { next(e); } +}); + +/* DELETE /api/ref-categories-enveloppes/:id — supprimer si non utilisée */ +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM categories_enveloppes WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_enveloppes WHERE categorie_id = ?').get(row.id).n; + if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} enveloppe(s) — retirez-la d'abord.`); + db.prepare('DELETE FROM categories_enveloppes WHERE id = ?').run(row.id); + res.status(204).end(); + } catch (e) { next(e); } +}); + +/* POST /api/ref-categories-enveloppes/:id/merge — fusionner dans une autre catégorie */ +router.post('/:id/merge', (req, res, next) => { + try { + const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body); + const source = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(req.params.id); + if (!source) throw new HttpError(404, 'Catégorie source introuvable'); + const target = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(target_id); + if (!target) throw new HttpError(404, 'Catégorie cible introuvable'); + if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques'); + + const result = db.transaction(() => { + const refs = db.prepare( + 'SELECT enveloppe_id FROM referentiel_categories_enveloppes WHERE categorie_id = ?' + ).all(source.id); + let added = 0, skipped = 0; + for (const { enveloppe_id } of refs) { + const exists = db.prepare( + 'SELECT 1 FROM referentiel_categories_enveloppes WHERE enveloppe_id = ? AND categorie_id = ?' + ).get(enveloppe_id, target.id); + if (!exists) { + db.prepare( + 'INSERT INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)' + ).run(enveloppe_id, target.id); + added++; + } else { + skipped++; + } + } + db.prepare('DELETE FROM referentiel_categories_enveloppes WHERE categorie_id = ?').run(source.id); + db.prepare('DELETE FROM categories_enveloppes WHERE id = ?').run(source.id); + return { added, skipped, total: refs.length }; + })(); + + res.json({ ok: true, ...result, + message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/supportsReferentiel.js b/backend/src/routes/supportsReferentiel.js new file mode 100644 index 0000000..af37f6c --- /dev/null +++ b/backend/src/routes/supportsReferentiel.js @@ -0,0 +1,102 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +/** + * routes/supportsReferentiel.js — Référentiel admin des supports d'investissement (fonds + * euros, ETF, SCPI, titres vifs...) — chantier "Référentiel Enveloppes & Supports d'épargne" + * (28/09/26, demande Olivier). + * + * Catalogue à plat (pas de sous-catégories — une dizaine d'entrées seulement, cf. "Types de + * supports (UC) disponibles" dans la liste fournie par Olivier), transverse à tous les espaces + * de travail comme les institutions financières. Consommé par enveloppesReferentiel.js pour la + * relation N-N enveloppe ↔ supports disponibles (enveloppes_supports_disponibles). + * + * Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion le 28/09/26) — + * aucun rattachement à l'existant (comptes, investissements...), en préparation du futur module + * patrimoine (cf. plan de déploiement, doc projet). + */ + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +const Schema = z.object({ + nom: z.string().min(1).max(200), + description: z.string().nullable().optional(), +}); + +// ── GET /api/supports-referentiel ─────────────────────────────────────────── +router.get('/', (_req, res) => { + const rows = db.prepare(` + SELECT s.*, COUNT(esd.enveloppe_id) AS nb_enveloppes + FROM supports_referentiel s + LEFT JOIN enveloppes_supports_disponibles esd ON esd.support_id = s.id + GROUP BY s.id + ORDER BY s.nom + `).all(); + res.json(rows); +}); + +// ── GET /api/supports-referentiel/:id ─────────────────────────────────────── +router.get('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Support introuvable'); + res.json(row); + } catch (e) { next(e); } +}); + +// ── POST /api/supports-referentiel ────────────────────────────────────────── +router.post('/', (req, res, next) => { + try { + const data = Schema.parse(req.body); + const existing = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?').get(data.nom); + if (existing) throw new HttpError(409, `Un support "${data.nom}" existe déjà`); + + const r = db.prepare(` + INSERT INTO supports_referentiel (nom, description, updated_at) + VALUES (?, ?, datetime('now')) + `).run(data.nom, data.description || null); + + const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(r.lastInsertRowid); + res.status(201).json(row); + } catch (e) { next(e); } +}); + +// ── PUT /api/supports-referentiel/:id ─────────────────────────────────────── +router.put('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Support introuvable'); + + const data = Schema.parse(req.body); + if (data.nom !== row.nom) { + const dup = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id); + if (dup) throw new HttpError(409, `Un support "${data.nom}" existe déjà`); + } + + db.prepare(` + UPDATE supports_referentiel + SET nom=?, description=?, updated_at=datetime('now') + WHERE id=? + `).run(data.nom, data.description || null, row.id); + + const updated = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(row.id); + res.json(updated); + } catch (e) { next(e); } +}); + +// ── DELETE /api/supports-referentiel/:id ──────────────────────────────────── +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM supports_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Support introuvable'); + // enveloppes_supports_disponibles a ON DELETE CASCADE sur support_id : les liens sont + // nettoyés automatiquement, aucune purge manuelle nécessaire ici. + db.prepare('DELETE FROM supports_referentiel WHERE id = ?').run(row.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index 7efd05c..c35dbff 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -53,6 +53,9 @@ import referentielPublicRouter from './routes/referentielPublic.js'; import institutionsReferentielRouter from './routes/institutionsReferentiel.js'; import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js'; import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js'; +import enveloppesReferentielRouter from './routes/enveloppesReferentiel.js'; +import supportsReferentielRouter from './routes/supportsReferentiel.js'; +import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js'; import refCategoriesRouter from './routes/ref-categories.js'; import refSecteursRouter from './routes/ref-secteurs.js'; import categoriesInvRouter from './routes/categories-inv.js'; @@ -176,6 +179,12 @@ app.use('/api/referentiel-public', requireAuth, referentielPublicRouter); app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter); app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter); app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter); +// Chantier "Référentiel Enveloppes & Supports d'épargne" (28/09/26, demande Olivier) — +// catalogue admin transverse aux workspaces, catalogue seul pour l'instant (pas de +// rattachement à l'existant), cf. enveloppesReferentiel.js. +app.use('/api/enveloppes-referentiel', requireAuth, requireAdmin, enveloppesReferentielRouter); +app.use('/api/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter); +app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter); app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter); app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); app.use('/api/categories-inv', requireAuth, categoriesInvRouter); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 726da77..41e4105 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,7 @@ import Admin from './pages/Admin.jsx'; import AdminPlateformes from './pages/AdminPlateformes.jsx'; import AdminFiscalite from './pages/AdminFiscalite.jsx'; import AdminInstitutions from './pages/AdminInstitutions.jsx'; +import AdminEnveloppes from './pages/AdminEnveloppes.jsx'; import AdminIA from './pages/AdminIA.jsx'; import Aide from './pages/Aide.jsx'; import PlatformeProfile from './pages/PlatformeProfile.jsx'; @@ -242,6 +243,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 82edd04..9b63a8c 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -17,6 +17,7 @@ function IconImage() { return ; } function IconDatabase() { return ; } function IconBank() { return ; } +function IconWallet() { return ; } function IconSettings() { return ; } function IconMail() { return ; } function IconDownload() { return ; } @@ -43,6 +44,7 @@ const NAV = [ { id: 'plateformes', label: 'Plateformes', icon: , href: '/admin/plateformes' }, { id: 'fiscalite', label: 'Fiscalité', icon: , href: '/admin/fiscalite' }, { id: 'institutions', label: 'Institutions financières', icon: , href: '/admin/institutions' }, + { id: 'enveloppes', label: 'Enveloppes & supports', icon: , href: '/admin/enveloppes' }, ], }, { diff --git a/frontend/src/pages/AdminEnveloppes.jsx b/frontend/src/pages/AdminEnveloppes.jsx new file mode 100644 index 0000000..087d58b --- /dev/null +++ b/frontend/src/pages/AdminEnveloppes.jsx @@ -0,0 +1,781 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import ConfirmModal from '../components/ConfirmModal.jsx'; +import Modal from '../components/Modal.jsx'; +import { usePagination } from '../hooks/usePagination.js'; +import Pagination from '../components/Pagination.jsx'; + +/** + * pages/AdminEnveloppes.jsx — Référentiel admin "Enveloppes & Supports d'épargne" + * (Livret A, Assurance-vie, PEA, PER... / fonds euros, ETF, SCPI...) — chantier du 28/09/26 + * (demande Olivier, cf. claude/plan_referentiel_enveloppes_supports.md). + * + * Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion) — aucun + * rattachement à l'existant (comptes, investissements...), en préparation du futur module + * patrimoine. Même esprit que AdminInstitutions.jsx : sous-page admin à onglets, montée sous + * /admin/enveloppes, avec son propre bouton retour vers /admin. + * + * Deux référentiels reliés (et non un seul catalogue plat) : + * - enveloppes_referentiel : les enveloppes fiscales/juridiques (Livret A, Assurance-vie...). + * - supports_referentiel : les supports d'investissement sous-jacents (fonds euros, ETF...). + * - enveloppes_supports_disponibles (N-N) : quels supports sont disponibles dans quelle + * enveloppe. Une enveloppe "cash" (Livret A, Compte courant...) n'a aucune ligne ici — le + * formulaire EnveloppeModal n'en présuppose donc jamais la présence. + * Catégories (familles) en N-N via referentiel_categories_enveloppes, propre à ce référentiel + * (categories_enveloppes), sur le même patron que categories_institutions. + */ + +/* ── Icônes ───────────────────────────────────────────────────── */ +function IconWallet() { + return ; +} +function IconLayers() { + return ; +} +function IconTag() { + return ; +} +function IconEdit() { + return ; +} +function IconTrash() { + return ; +} +function IconChevronLeft() { + return ; +} + +const eur = n => (n == null ? '—' : `${Number(n).toLocaleString('fr-FR')} €`); + +/* ── Modale de création/édition d'une enveloppe ────────────────── + Formulaire simple à un seul écran : nom, description, plafond, régime fiscal, catégories, + et supports disponibles (checkboxes — laissées vides pour une enveloppe "cash" comme le + Livret A ou un compte courant, qui n'offre aucun choix de support). */ +function EnveloppeModal({ row, onClose, onSaved }) { + const isNew = !row?.id; + const [categories, setCategories] = useState([]); + const [supports, setSupports] = useState([]); + const [err, setErr] = useState(null); + const [saving, setSaving] = useState(false); + + const [form, setForm] = useState({ + nom: row?.nom || '', + description: row?.description || '', + plafond_eur: row?.plafond_eur != null ? String(row.plafond_eur) : '', + regime_fiscal: row?.regime_fiscal || '', + categories_ids: (row?.categories || []).map(c => c.id), + supports_ids: (row?.supports || []).map(s => s.id), + }); + + useEffect(() => { + api.get('/ref-categories-enveloppes').then(setCategories).catch(() => {}); + api.get('/supports-referentiel').then(setSupports).catch(() => {}); + }, []); + + const set = (k, v) => setForm(f => ({ ...f, [k]: v })); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!form.nom.trim()) { setErr('Le nom est requis.'); return; } + const plafond = form.plafond_eur.trim() ? Number(form.plafond_eur.replace(',', '.')) : null; + if (plafond != null && (!Number.isFinite(plafond) || plafond <= 0)) { setErr('Le plafond doit être un nombre positif.'); return; } + setSaving(true); setErr(null); + try { + const payload = { + nom: form.nom.trim(), + description: form.description.trim() || null, + plafond_eur: plafond, + regime_fiscal: form.regime_fiscal.trim() || null, + categories_ids: form.categories_ids || [], + supports_ids: form.supports_ids || [], + }; + if (isNew) await api.post('/enveloppes-referentiel', payload); + else await api.put(`/enveloppes-referentiel/${row.id}`, payload); + onSaved(); onClose(); + } catch (ex) { setErr(ex.message); } + finally { setSaving(false); } + }; + + return ( + + + + + } + > +
+ {err &&
{err}
} +
+ + set('nom', e.target.value)} placeholder="Ex : Livret A, Assurance-vie, PEA…" /> +
+
+
+ + set('plafond_eur', e.target.value)} placeholder="Ex : 22950 — laisser vide si sans plafond" /> +
+
+ + set('regime_fiscal', e.target.value)} placeholder="Ex : Exonéré IR/PS, Flat tax 30%…" /> +
+
+
+ +
+ {categories.length === 0 && Aucune catégorie — gérez-les depuis l'onglet "Catégories".} + {categories.map(c => { + const checked = (form.categories_ids || []).includes(c.id); + return ( + + ); + })} +
+
+
+ +

+ Laisser vide pour une enveloppe "cash" sans choix de support (Livret A, Compte courant…). +

+
+ {supports.length === 0 && Aucun support — gérez-les depuis l'onglet "Supports".} + {supports.map(s => { + const checked = (form.supports_ids || []).includes(s.id); + return ( + + ); + })} +
+
+
+ +