This commit is contained in:
ocroguennec committed 2026-09-27 15:09:18 +02:00
1 parent 1e3994f17f
commit 624d32ff7c
17 files changed
+2192 -1

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = """ { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
];"""
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
new_item = """ { nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
{ nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] },
];"""
content = content.replace(anchor, new_item, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Crowdlending enveloppe inserted OK, new length:", len(content))
@@ -0,0 +1,36 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# 1) Nouvelle catégorie "Investissement forestier & viticole" (GFF + GFV) ajoutée à la liste des
# catégories toujours insérées (INSERT OR IGNORE).
anchor_cat = " for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection']) {"
assert content.count(anchor_cat) == 1, f"anchor_cat count={content.count(anchor_cat)}"
new_cat = " for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection', 'Investissement forestier & viticole']) {"
content = content.replace(anchor_cat, new_cat, 1)
# 2) 7 nouvelles enveloppes ajoutées à EXTRA_ENVELOPPES (demande Olivier du 27/09/26, à partir
# d'une capture d'écran d'un site de conseil en gestion de patrimoine listant des placements
# absents du référentiel) : Dette privée, capital-investissement réservé (FPCI/SLP/FPS),
# Infrastructures non cotées, Groupement Foncier Forestier, Groupement Foncier Viticole,
# LMNP/LMP (distinct de l'immobilier locatif nu déjà présent), SCPI en démembrement.
anchor_last_item = """ { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] },
];"""
assert content.count(anchor_last_item) == 1, f"anchor_last_item count={content.count(anchor_last_item)}"
new_items = """ { nom: 'Crowdlending (prêts aux entreprises/PME)', categories: ['Financement participatif / private equity'], description: "Prêt participatif à des entreprises/PME hors immobilier (le coeur de métier de cette application) — distinct de « Crowdfunding immobilier » (prêts adossés à une opération immobilière) et de « Crowdequity » (prise de participation au capital, pas un prêt).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)", supports: [] },
{ nom: 'Dette privée (fonds)', categories: ['Financement participatif / private equity'], description: "Fonds de dette privée (private debt) — prêts directs à des entreprises via un véhicule de fonds professionnel, hors plateforme de crowdlending grand public.", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] },
{ nom: 'Capital-investissement réservé (FPCI / SLP / FPS)', categories: ['Financement participatif / private equity'], description: "Fonds de capital-investissement réservés aux investisseurs avertis/professionnels — Fonds Professionnel de Capital Investissement (FPCI), Société de Libre Partenariat (SLP), Fonds Professionnel Spécialisé (FPS) ; distinct du FCPR grand public (support disponible en assurance-vie/PER/contrat de capitalisation) et de la participation directe non cotée (prise de participation en direct, hors fonds).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les plus-values de cession de parts (flat tax / PFU, ou option barème) ; exonération possible sous conditions de durée de détention, comme pour un FCPR", supports: [] },
{ nom: 'Infrastructures non cotées', categories: ['Financement participatif / private equity'], description: "Fonds d'investissement dans des actifs d'infrastructure non cotés (énergie, transport, télécoms...).", plafond_eur: null, regime_fiscal: "Fiscalité classique sur les revenus/plus-values de cession de parts (flat tax / PFU, ou option barème)", supports: [] },
{ nom: 'Groupement Foncier Forestier (GFF)', categories: ['Investissement forestier & viticole'], description: "Investissement forestier via un groupement foncier forestier — parts donnant une quote-part de bois et forêts en gestion collective.", plafond_eur: null, regime_fiscal: "Réduction d'IR à l'entrée possible sous conditions (dispositif DEFI-Forêt), exonération partielle d'IFI, transmission facilitée sous conditions d'engagement de gestion durable", supports: [] },
{ nom: 'Groupement Foncier Viticole (GFV)', categories: ['Investissement forestier & viticole'], description: "Investissement viticole via un groupement foncier viticole — parts donnant une quote-part de vignes louées à un exploitant via un bail rural.", plafond_eur: null, regime_fiscal: "Revenus fonciers (loyers du bail rural), exonération partielle d'IFI et de droits de succession/donation sous conditions", supports: [] },
{ nom: 'Immobilier locatif meublé (LMNP/LMP)', categories: ['Immobilier physique'], description: "Location meublée en direct — statut Loueur en Meublé Non Professionnel (LMNP) ou Professionnel (LMP) selon les seuils de recettes et la composition du foyer fiscal ; distinct de « Immobilier locatif (détention directe) » qui suppose une location nue (revenus fonciers).", plafond_eur: null, regime_fiscal: "Revenus imposés en BIC (micro-BIC ou régime réel avec amortissement du bien) ; le statut LMP ouvre en plus à l'imputation des déficits sur le revenu global et à un régime de plus-value professionnelle", supports: [] },
{ nom: 'SCPI en démembrement (nue-propriété / usufruit temporaire)', categories: ['Immobilier papier'], description: "SCPI achetée en démembrement de propriété (nue-propriété ou usufruit temporaire), typiquement sur 5 à 10 ans.", plafond_eur: null, regime_fiscal: "Usufruitier : perçoit les loyers et est imposé en revenus fonciers pendant la durée du démembrement. Nu-propriétaire : aucun revenu ni imposition pendant la période, récupère la pleine propriété sans fiscalité à l'extinction ; non redevable de l'IFI sur la valeur du bien démembré pendant la durée (sauf exceptions)", supports: [] },
];"""
content = content.replace(anchor_last_item, new_items, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Dette privée / FPCI-SLP-FPS / Infrastructures / GFF / GFV / LMNP-LMP / SCPI démembrement inserted OK, new length:", len(content))
+47
View File
@@ -0,0 +1,47 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = "\nexport default db;\n"
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = '''
// Extension du 29/09/26 (suite) — support "ETC" (demande Olivier, à partir d'une capture d'écran
// d'un contrat d'assurance-vie distinguant "+200 ETF & 22 ETC" parmi ses supports) : un Exchange
// Traded Commodity (ETC) est un instrument coté qui réplique le cours d'une matière première
// (or, pétrole...), proche d'un ETF dans sa mécanique de cotation mais sans statut UCITS/OPCVM —
// distinct du support "ETF / trackers" (fonds indiciels UCITS) et de "Matières premières / métaux
// précieux" (autre modalité d'exposition aux matières premières, ex. en unités de compte
// d'assurance-vie). Rattaché aux mêmes enveloppes que "Fonds monétaires" ci-dessus (menu de
// supports déjà large) ; PEA/PEA-PME exclus, comme en pratique un ETC matières premières
// physiques n'est pas éligible au PEA. Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom).
{
db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run(
'ETC (Exchange Traded Commodities)',
"Tracker coté sur une matière première (or, pétrole, panier de matières premières...) — proche de l'ETF dans sa mécanique de cotation, mais sans statut UCITS/OPCVM ; distinct du support « ETF / trackers » et de « Matières premières / métaux précieux »."
);
const getSuppRefIdEtc = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?');
const suppEtc = getSuppRefIdEtc.get('ETC (Exchange Traded Commodities)');
if (suppEtc) {
const getEnvRefIdEtc = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const linkEnvSuppEtc = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)');
for (const envNom of [
'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel',
'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation',
'Assurance-vie luxembourgeoise',
]) {
const env = getEnvRefIdEtc.get(envNom);
if (env) linkEnvSuppEtc.run(env.id, suppEtc.id);
}
}
}
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Support ETC inserted OK, new length:", len(content))
+84
View File
@@ -0,0 +1,84 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# 1) Insérer la liste des 11 supports (dupliquée localement, hors de portée du AV_PER_SUPPORTS
# du bloc de seed initial) juste avant EXTRA_ENVELOPPES.
anchor_supports = " const EXTRA_ENVELOPPES = [\n"
assert content.count(anchor_supports) == 1, f"anchor_supports count={content.count(anchor_supports)}"
supports_const = """ const AV_PER_SUPPORTS_EXTRA = [
'Fonds en euros', 'OPCVM / fonds actifs', 'ETF / trackers', 'Titres vifs (actions)',
'Obligations / fonds datés', 'Immobilier papier (SCPI/SCI/OPCI)', 'Private equity (FCPR)',
'Produits structurés', 'Matières premières / métaux précieux', 'Gestion pilotée / mandat',
'Fonds solidaires (90/10)',
];
"""
content = content.replace(anchor_supports, supports_const + anchor_supports, 1)
# 2) Ajouter les nouvelles enveloppes à la fin du tableau EXTRA_ENVELOPPES : Contrat de
# capitalisation, Assurance-vie luxembourgeoise, Girardin (demande du 29/09/26 sur les
# enveloppes manquantes / actifs "exotiques"), et Wallet crypto conservé par un tiers
# (demande du 29/09/26, distincte de l'auto-conservation et du compte sur exchange).
anchor_last_item = """ { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] },
];"""
assert content.count(anchor_last_item) == 1, f"anchor_last_item count={content.count(anchor_last_item)}"
new_items = """ { nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] },
{ nom: 'Contrat de capitalisation', categories: ['Assurance-vie'], description: "Support d'épargne proche de l'assurance-vie (fonds euros + unités de compte), mais sans clause bénéficiaire ni dénouement au décès — entre dans la succession classique. Peut être souscrit par une personne physique ou une personne morale (société, SCI...).", plafond_eur: null, regime_fiscal: "Fiscalité des gains identique à l'assurance-vie pour un particulier (abattement après 8 ans, PFU ou PFL) ; pas de régime de succession spécifique (articles 990 I / 757 B) ; transmissible par donation du vivant, contrairement à l'assurance-vie", supports: AV_PER_SUPPORTS_EXTRA },
{ nom: 'Assurance-vie luxembourgeoise', categories: ['Assurance-vie'], description: "Contrat d'assurance-vie souscrit auprès d'un assureur luxembourgeois — triangle de sécurité (actifs ségrégués auprès d'une banque dépositaire distincte de l'assureur), univers d'investissement et devises généralement plus larges qu'un contrat français.", plafond_eur: null, regime_fiscal: "Fiscalité identique à l'assurance-vie française pour un résident fiscal français (abattement après 8 ans, PFU/PFL) ; déclaration du contrat étranger requise (formulaire 3916-3916 bis)", supports: AV_PER_SUPPORTS_EXTRA },
{ nom: 'Girardin (industriel / social)', categories: ['Financement participatif / private equity'], description: "Investissement dans un dispositif de défiscalisation outre-mer — financement d'un investissement productif ou de logement social en DOM-TOM, en échange d'une réduction d'impôt immédiate. Capital non récupéré (pas de valeur résiduelle), contrairement à un FCPI/FIP.", plafond_eur: null, regime_fiscal: "Réduction d'IR l'année suivant l'investissement, généralement supérieure au montant investi (effet de levier) ; plafonnement spécifique des niches fiscales outre-mer ; risque de remise en cause fiscale si le monteur/exploitant ne respecte pas ses engagements (durée de conservation 5 ans)", supports: [] },
{ nom: 'Wallet crypto (conservation par un tiers)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues dans un wallet dont les clés sont conservées par un tiers de confiance (service de custody dédié, family office...), par opposition à l'auto-conservation ou à un compte sur une plateforme d'échange.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
];"""
content = content.replace(anchor_last_item, new_items, 1)
# 3) Étendre la boucle de traitement pour gérer aussi les supports (jusqu'ici uniquement les
# catégories, car aucune des 7 enveloppes du 29/09/26 n'avait de support).
anchor_loop = """ const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)');
const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?');
const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)');
db.transaction(() => {
for (const { nom, description, plafond_eur, regime_fiscal, categories } of EXTRA_ENVELOPPES) {
insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null);
const env = getEnvRefIdExtra.get(nom);
if (!env) continue;
for (const catNom of categories) {
const cat = getCatEnvIdExtra.get(catNom);
if (cat) linkEnvCatExtra.run(env.id, cat.id);
}
}
})();
}"""
assert content.count(anchor_loop) == 1, f"anchor_loop count={content.count(anchor_loop)}"
new_loop = """ const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)');
const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?');
const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)');
const getSuppRefIdExtra = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?');
const linkEnvSuppExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)');
db.transaction(() => {
for (const { nom, description, plafond_eur, regime_fiscal, categories, supports } of EXTRA_ENVELOPPES) {
insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null);
const env = getEnvRefIdExtra.get(nom);
if (!env) continue;
for (const catNom of categories) {
const cat = getCatEnvIdExtra.get(catNom);
if (cat) linkEnvCatExtra.run(env.id, cat.id);
}
for (const suppNom of (supports || [])) {
const supp = getSuppRefIdExtra.get(suppNom);
if (supp) linkEnvSuppExtra.run(env.id, supp.id);
}
}
})();
}"""
content = content.replace(anchor_loop, new_loop, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Extension 2 (contrat de capitalisation, AV luxembourgeoise, Girardin, wallet tiers) inserted OK, new length:", len(content))
+71
View File
@@ -0,0 +1,71 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = """db.prepare(`
UPDATE enveloppes_referentiel SET plafond_eur = 61200, updated_at = datetime('now')
WHERE nom = 'PEL' AND plafond_eur IS NULL
`).run();
export default db;
"""
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = '''db.prepare(`
UPDATE enveloppes_referentiel SET plafond_eur = 61200, updated_at = datetime('now')
WHERE nom = 'PEL' AND plafond_eur IS NULL
`).run();
// Extension du 29/09/26 (demande Olivier) : "je ne voudrais pas faire un nième référentiel [...]
// il faut peut-être que cela soit des catégories supplémentaires même si ce n'a pas tout à fait
// le même statut" — plutôt qu'un 3e référentiel séparé pour les actifs "physiques"/hors
// enveloppe fiscale (immobilier direct, crypto, métaux précieux, objets de collection,
// participations non cotées), ils sont intégrés au référentiel existant : quelques nouvelles
// catégories (familles) dans categories_enveloppes, et des enveloppes rattachées, sur le même
// principe que les enveloppes SCPI/OPCI/SCI détenues en direct déjà présentes (aucun support —
// détention directe, pas de menu de supports ; aucun institution_id — catalogue seul, comme le
// reste de ce référentiel). Les participations non cotées réutilisent la catégorie existante
// "Financement participatif / private equity" plutôt que d'en créer une nouvelle (même famille
// que le Crowdequity et les FCPI/FIP). Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom)
// — s'ajoute sans écraser à un référentiel déjà seedé, comme le backfill CEL/PEL ci-dessus.
{
const insCatEnvExtra = db.prepare('INSERT OR IGNORE INTO categories_enveloppes (nom) VALUES (?)');
for (const nom of ['Immobilier physique', 'Actifs numériques', 'Métaux précieux', 'Objets de collection']) {
insCatEnvExtra.run(nom);
}
const EXTRA_ENVELOPPES = [
{ nom: 'Résidence principale', categories: ['Immobilier physique'], description: "Logement occupé à titre de résidence principale.", plafond_eur: null, regime_fiscal: "Plus-value de cession exonérée (résidence principale)", supports: [] },
{ nom: 'Immobilier locatif (détention directe)', categories: ['Immobilier physique'], description: "Bien immobilier loué, détenu en direct (hors SCPI/SCI).", plafond_eur: null, regime_fiscal: "Revenus fonciers (micro-foncier ou réel) + plus-value immobilière (abattement selon durée de détention)", supports: [] },
{ nom: 'Compte crypto (exchange)', categories: ['Actifs numériques'], description: "Cryptomonnaies détenues sur une plateforme d'échange centralisée.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
{ nom: 'Wallet crypto (auto-conservation)', categories: ['Actifs numériques'], description: "Cryptomonnaies en auto-conservation (wallet matériel ou logiciel).", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086", supports: [] },
{ nom: 'Métaux précieux physiques', categories: ['Métaux précieux'], description: "Or, argent... détenus physiquement (lingots, pièces).", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les métaux précieux (11 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] },
{ nom: 'Objets de collection', categories: ['Objets de collection'], description: "Montres, art, antiquités...", plafond_eur: null, regime_fiscal: "Taxe forfaitaire sur les objets d'art/collection/antiquité (6 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat", supports: [] },
{ nom: 'Participation directe non cotée (startups/PME)', categories: ['Financement participatif / private equity'], description: "Prise de participation directe au capital d'une société non cotée, hors plateforme crowdequity.", plafond_eur: null, regime_fiscal: "Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)", supports: [] },
];
const insEnvRefExtra = db.prepare('INSERT OR IGNORE INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal) VALUES (?, ?, ?, ?)');
const getEnvRefIdExtra = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const getCatEnvIdExtra = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?');
const linkEnvCatExtra = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)');
db.transaction(() => {
for (const { nom, description, plafond_eur, regime_fiscal, categories } of EXTRA_ENVELOPPES) {
insEnvRefExtra.run(nom, description ?? null, plafond_eur ?? null, regime_fiscal ?? null);
const env = getEnvRefIdExtra.get(nom);
if (!env) continue;
for (const catNom of categories) {
const cat = getCatEnvIdExtra.get(catNom);
if (cat) linkEnvCatExtra.run(env.id, cat.id);
}
}
})();
}
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Extension actifs directs inserted OK, new length:", len(content))
+58
View File
@@ -0,0 +1,58 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
anchor = """ })();
}
export default db;
"""
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = ''' })();
}
// Extension du 29/09/26 (demande Olivier, à propos de produits Spiko/Regular Finance) : "sais-tu
// comment les classifier ?" — Spiko et Regular Finance ne sont pas des enveloppes mais des
// PLATEFORMES/INSTITUTIONS donnant accès à un fonds monétaire (OPCVM monétaire, y compris
// lorsqu'il est tokenisé sur blockchain comme chez Spiko) : elles ont vocation à rejoindre
// institutions_referentiel (catégorie à créer, ex. "Gestion de trésorerie"), pas
// enveloppes_referentiel — le rattachement viendra en Phase 3. Ce qui manquait réellement à CE
// référentiel, c'est le SUPPORT lui-même : "OPCVM / fonds actifs" existant désigne des fonds
// actions/obligations/diversifiés gérés activement, pas un fonds monétaire (quasi-cash, duration
// très courte, profil de risque très différent) — les deux ne doivent pas être confondus.
// Toujours exécuté (INSERT OR IGNORE, clés UNIQUE sur nom), même principe que les extensions
// précédentes.
{
db.prepare("INSERT OR IGNORE INTO supports_referentiel (nom, description) VALUES (?, ?)").run(
'Fonds monétaires (OPCVM monétaire)',
"Fonds monétaire classique ou tokenisé sur blockchain (ex. Spiko, Regular Finance) — quasi-cash, duration très courte, distinct d'un OPCVM actions/obligations/diversifié."
);
const getSuppRefIdMonetaire = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?');
const suppMonetaire = getSuppRefIdMonetaire.get('Fonds monétaires (OPCVM monétaire)');
if (suppMonetaire) {
const getEnvRefIdMonetaire = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?');
const linkEnvSuppMonetaire = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?, ?)');
// Enveloppes où un fonds monétaire a sa place (menu de supports déjà large, cohérent avec un
// placement défensif) — PEA/PEA-PME exclus (non éligibles réglementairement), enveloppes
// "cash" (Livret A, comptes courants...) exclues (déjà sans menu de supports par nature).
for (const envNom of [
'Compte-titres ordinaire (CTO)', 'Assurance-vie multisupport', 'PER individuel',
'PER collectif / obligatoire (PERCOL, article 83)', 'Contrat de capitalisation',
'Assurance-vie luxembourgeoise',
]) {
const env = getEnvRefIdMonetaire.get(envNom);
if (env) linkEnvSuppMonetaire.run(env.id, suppMonetaire.id);
}
}
}
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Fonds monétaires support + links inserted OK, new length:", len(content))
@@ -0,0 +1,53 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# 1) Retrait de "Gestion pilotée / mandat" du seed initial des supports (SEED_SUPPORTS) — ne
# concerne que les installations neuves, ce bloc étant gardé par COUNT()===0.
old_seed_line = " { nom: 'Gestion pilotée / mandat', description: 'Allocation déléguée à un gérant selon un profil de risque — mode par défaut de nombreux PER.' },\n"
assert content.count(old_seed_line) == 1, f"old_seed_line count={content.count(old_seed_line)}"
content = content.replace(old_seed_line, "", 1)
# 2) Retrait de 'Gestion pilotée / mandat' des deux listes de supports "classiques" AV/PER
# (AV_PER_SUPPORTS et AV_PER_SUPPORTS_EXTRA) — présent dans les deux car dupliqué (la seconde
# liste, hors de portée du bloc de seed initial, a été recopiée lors de l'extension du 29/09/26
# pour Contrat de capitalisation / Assurance-vie luxembourgeoise).
old_list_fragment = "'Produits structurés', 'Matières premières / métaux précieux', 'Gestion pilotée / mandat',"
assert content.count(old_list_fragment) == 2, f"old_list_fragment count={content.count(old_list_fragment)}"
new_list_fragment = "'Produits structurés', 'Matières premières / métaux précieux',"
content = content.replace(old_list_fragment, new_list_fragment) # replace_all (2 occurrences)
# 3) Nouveau bloc toujours exécuté : supprime la ligne "Gestion pilotée / mandat" pour les
# installations déjà seedées (le retrait du texte source ci-dessus ne corrige que les
# installations neuves). ON DELETE CASCADE sur enveloppes_supports_disponibles nettoie
# automatiquement les liens existants vers ce support — c'est un pur catalogue (aucune donnée
# d'investissement réelle n'y est aujourd'hui rattachée), donc la suppression est sans risque.
# Toujours exécuté et idempotent (DELETE sans WHERE de garde spécifique au-delà du nom — un
# deuxième passage ne trouve simplement plus rien à supprimer).
anchor = "\nexport default db;\n"
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
block = '''
// Correctif du 29/09/26 (suite) — retrait du support "Gestion pilotée / mandat" (demande
// Olivier, à propos d'une capture d'écran distinguant "3 modes de gestion" — libre / pilotée /
// mixte — d'un contrat d'assurance-vie) : le "mode de gestion" décrit QUI décide de l'allocation
// (le titulaire, ou un gérant délégué selon un mandat), c'est une dimension orthogonale au
// SUPPORT (le type d'instrument financier détenu — ETF, actions, SCPI...). Une gestion pilotée
// s'applique justement à un panier de vrais supports (fonds euros, fonds datés, SCPI, produits
// structurés...), elle n'en est pas un elle-même. Cette ligne datait du seed initial du 28/09/26
// et n'aurait pas dû être modélisée comme support ; elle est retirée du référentiel et de la
// liste de supports "classiques" AV/PER (voir plus haut). Toujours exécuté et idempotent —
// DELETE cascade automatiquement les liens vers cette entrée (enveloppes_supports_disponibles,
// ON DELETE CASCADE) ; aucune donnée d'investissement réelle n'est rattachée à ce catalogue à ce
// stade, la suppression est donc sans risque de perte de données utilisateur.
db.prepare("DELETE FROM supports_referentiel WHERE nom = 'Gestion pilotée / mandat'").run();
export default db;
'''
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Gestion pilotée / mandat removed from supports OK, new length:", len(content))
@@ -0,0 +1,102 @@
path = "backend/src/db/index.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# Liste des 12 lignes à corriger : (nom, ancien texte exact regime_fiscal, nouveau texte)
FIXES = [
('Compte courant rémunéré',
'Flat tax 30 %',
'Flat tax (PFU)'),
('Compte rémunéré nouvelle génération (fintech)',
'Flat tax 30 %',
'Flat tax (PFU)'),
('Livret bancaire non réglementé',
'Flat tax 30 % (ou option barème)',
'Flat tax (PFU) ou option barème'),
('Compte à terme (CAT)',
'Flat tax 30 % (ou option barème)',
'Flat tax (PFU) ou option barème'),
('Compte-titres ordinaire (CTO)',
"Flat tax 30 % (ou option barème), pas d'avantage fiscal",
"Flat tax (PFU) ou option barème, pas d'avantage fiscal"),
('Crowdfunding immobilier',
'Fiscalité classique sur les intérêts (flat tax 30 % ou barème)',
'Fiscalité classique sur les intérêts (flat tax / PFU, ou option barème)'),
('Compte crypto (exchange)',
"Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086",
"Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"),
('Wallet crypto (auto-conservation)',
"Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086",
"Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"),
('Métaux précieux physiques',
"Taxe forfaitaire sur les métaux précieux (11 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat",
"Taxe forfaitaire sur les métaux précieux, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat"),
('Objets de collection',
"Taxe forfaitaire sur les objets d'art/collection/antiquité (6 %), ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat",
"Taxe forfaitaire sur les objets d'art/collection/antiquité, ou option pour le régime des plus-values sur biens meubles si justificatifs d'achat"),
('Participation directe non cotée (startups/PME)',
"Flat tax 30 % sur les plus-values (ou barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)",
"Flat tax (PFU) sur les plus-values (ou option barème) ; réduction d'IR à l'entrée possible sous conditions (IR-PME/Madelin)"),
('Wallet crypto (conservation par un tiers)',
"Flat tax 30 % sur les plus-values de cession (particuliers), déclaration 2086",
"Flat tax (PFU) sur les plus-values de cession (particuliers), déclaration 2086"),
]
# 1) Réécriture du texte source, ligne par ligne (chaque ligne contient "nom: '<X>'," ce qui la
# rend unique même quand deux lignes partagent le même ancien texte regime_fiscal).
lines = content.split("\n")
touched = {nom: False for nom, _, _ in FIXES}
for i, line in enumerate(lines):
for nom, old_txt, new_txt in FIXES:
needle_nom = f"nom: '{nom}'"
if needle_nom in line and old_txt in line and not touched[nom]:
assert line.count(old_txt) == 1, f"{nom}: old_txt found {line.count(old_txt)} times on its line"
lines[i] = line.replace(old_txt, new_txt, 1)
touched[nom] = True
break
missing = [nom for nom, ok in touched.items() if not ok]
assert not missing, f"lignes non trouvées pour : {missing}"
content = "\n".join(lines)
# 2) Bloc de backfill toujours exécuté (INSERT OR IGNORE ne corrige pas le texte des lignes déjà
# présentes) : UPDATE ... WHERE nom = ? AND regime_fiscal = '<ancien texte exact>' — ne touche
# jamais une ligne dont regime_fiscal a été modifié manuellement (le texte ne matchera plus).
anchor = "\nexport default db;\n"
assert content.count(anchor) == 1, f"anchor count={content.count(anchor)}"
backfill_stmts = []
for nom, old_txt, new_txt in FIXES:
nom_js = nom.replace("\\", "\\\\").replace("'", "\\'")
old_js = old_txt.replace("\\", "\\\\").replace("'", "\\'")
new_js = new_txt.replace("\\", "\\\\").replace("'", "\\'")
backfill_stmts.append(
" db.prepare(\n"
" \"UPDATE enveloppes_referentiel SET regime_fiscal = ?, updated_at = datetime('now') \" +\n"
" \"WHERE nom = ? AND regime_fiscal = ?\"\n"
f" ).run('{new_js}', '{nom_js}', '{old_js}');"
)
block = (
"\n"
"// Correctif du 27/09/26 (demande Olivier : \"Tu alimente la description avec un flat tax à\n"
"// 30%, mais ce n'est plus vrai en 2026... évite de mettre le taux actuel qui peut varier\") —\n"
"// les taux d'imposition précis (flat tax 30 %, taxes forfaitaires 11 %/6 %...) changent dans\n"
"// le temps et rien ici ne les tient à jour : les textes ci-dessus (SEED_ENVELOPPES /\n"
"// EXTRA_ENVELOPPES) ont été réécrits pour décrire le MÉCANISME fiscal (flat tax/PFU, taxe\n"
"// forfaitaire...) sans figer de pourcentage. Comme INSERT OR IGNORE ne corrige pas le texte\n"
"// d'une ligne déjà présente, ce backfill patche les installations déjà seedées — toujours\n"
"// exécuté, guardé par l'ancien texte exact pour ne jamais écraser une modification manuelle\n"
"// faite depuis l'admin (même principe que le backfill des plafonds CEL/PEL).\n"
"{\n"
+ "\n".join(backfill_stmts) + "\n"
"}\n"
"\nexport default db;\n"
)
content = content.replace(anchor, block, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("Regime fiscal genericization + backfill inserted OK, new length:", len(content))
+417
View File
@@ -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; export default db;
+174
View File
@@ -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;
@@ -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;
+102
View File
@@ -0,0 +1,102 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/supportsReferentiel.js — Référentiel admin des supports d'investissement (fonds
* euros, ETF, SCPI, titres vifs...) — chantier "Référentiel Enveloppes & Supports d'épargne"
* (28/09/26, demande Olivier).
*
* Catalogue à plat (pas de sous-catégories — une dizaine d'entrées seulement, cf. "Types de
* supports (UC) disponibles" dans la liste fournie par Olivier), transverse à tous les espaces
* de travail comme les institutions financières. Consommé par enveloppesReferentiel.js pour la
* relation N-N enveloppe ↔ supports disponibles (enveloppes_supports_disponibles).
*
* Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion le 28/09/26) —
* aucun rattachement à l'existant (comptes, investissements...), en préparation du futur module
* patrimoine (cf. plan de déploiement, doc projet).
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
description: z.string().nullable().optional(),
});
// ── GET /api/supports-referentiel ───────────────────────────────────────────
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT s.*, COUNT(esd.enveloppe_id) AS nb_enveloppes
FROM supports_referentiel s
LEFT JOIN enveloppes_supports_disponibles esd ON esd.support_id = s.id
GROUP BY s.id
ORDER BY s.nom
`).all();
res.json(rows);
});
// ── GET /api/supports-referentiel/:id ───────────────────────────────────────
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
res.json(row);
} catch (e) { next(e); }
});
// ── POST /api/supports-referentiel ──────────────────────────────────────────
router.post('/', (req, res, next) => {
try {
const data = Schema.parse(req.body);
const existing = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?').get(data.nom);
if (existing) throw new HttpError(409, `Un support "${data.nom}" existe déjà`);
const r = db.prepare(`
INSERT INTO supports_referentiel (nom, description, updated_at)
VALUES (?, ?, datetime('now'))
`).run(data.nom, data.description || null);
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(r.lastInsertRowid);
res.status(201).json(row);
} catch (e) { next(e); }
});
// ── PUT /api/supports-referentiel/:id ───────────────────────────────────────
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
const data = Schema.parse(req.body);
if (data.nom !== row.nom) {
const dup = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id);
if (dup) throw new HttpError(409, `Un support "${data.nom}" existe déjà`);
}
db.prepare(`
UPDATE supports_referentiel
SET nom=?, description=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.description || null, row.id);
const updated = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(row.id);
res.json(updated);
} catch (e) { next(e); }
});
// ── DELETE /api/supports-referentiel/:id ────────────────────────────────────
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
// enveloppes_supports_disponibles a ON DELETE CASCADE sur support_id : les liens sont
// nettoyés automatiquement, aucune purge manuelle nécessaire ici.
db.prepare('DELETE FROM supports_referentiel WHERE id = ?').run(row.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
+9
View File
@@ -53,6 +53,9 @@ import referentielPublicRouter from './routes/referentielPublic.js';
import institutionsReferentielRouter from './routes/institutionsReferentiel.js'; import institutionsReferentielRouter from './routes/institutionsReferentiel.js';
import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js'; import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.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 refCategoriesRouter from './routes/ref-categories.js';
import refSecteursRouter from './routes/ref-secteurs.js'; import refSecteursRouter from './routes/ref-secteurs.js';
import categoriesInvRouter from './routes/categories-inv.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', requireAuth, requireAdmin, institutionsReferentielRouter);
app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter); app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter);
app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter); 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-categories', requireAuth, requireAdmin, refCategoriesRouter);
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
app.use('/api/categories-inv', requireAuth, categoriesInvRouter); app.use('/api/categories-inv', requireAuth, categoriesInvRouter);
+2
View File
@@ -30,6 +30,7 @@ import Admin from './pages/Admin.jsx';
import AdminPlateformes from './pages/AdminPlateformes.jsx'; import AdminPlateformes from './pages/AdminPlateformes.jsx';
import AdminFiscalite from './pages/AdminFiscalite.jsx'; import AdminFiscalite from './pages/AdminFiscalite.jsx';
import AdminInstitutions from './pages/AdminInstitutions.jsx'; import AdminInstitutions from './pages/AdminInstitutions.jsx';
import AdminEnveloppes from './pages/AdminEnveloppes.jsx';
import AdminIA from './pages/AdminIA.jsx'; import AdminIA from './pages/AdminIA.jsx';
import Aide from './pages/Aide.jsx'; import Aide from './pages/Aide.jsx';
import PlatformeProfile from './pages/PlatformeProfile.jsx'; import PlatformeProfile from './pages/PlatformeProfile.jsx';
@@ -242,6 +243,7 @@ export default function App() {
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} /> <Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} /> <Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
<Route path="admin/institutions" element={<AdminOnly><AdminInstitutions /></AdminOnly>} /> <Route path="admin/institutions" element={<AdminOnly><AdminInstitutions /></AdminOnly>} />
<Route path="admin/enveloppes" element={<AdminOnly><AdminEnveloppes /></AdminOnly>} />
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} /> <Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
<Route path="aide" element={<Aide />} /> <Route path="aide" element={<Aide />} />
<Route path="notifications" element={<Notifications />} /> <Route path="notifications" element={<Notifications />} />
+2
View File
@@ -17,6 +17,7 @@ function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24"
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; } function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; } function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
function IconBank() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="3" y1="22" x2="21" y2="22"/><line x1="6" y1="18" x2="6" y2="11"/><line x1="10" y1="18" x2="10" y2="11"/><line x1="14" y1="18" x2="14" y2="11"/><line x1="18" y1="18" x2="18" y2="11"/><polygon points="12 2 21 8 3 8"/></svg>; } function IconBank() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="3" y1="22" x2="21" y2="22"/><line x1="6" y1="18" x2="6" y2="11"/><line x1="10" y1="18" x2="10" y2="11"/><line x1="14" y1="18" x2="14" y2="11"/><line x1="18" y1="18" x2="18" y2="11"/><polygon points="12 2 21 8 3 8"/></svg>; }
function IconWallet() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>; }
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; } function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; } function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
function IconDownload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; } function IconDownload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; }
@@ -43,6 +44,7 @@ const NAV = [
{ id: 'plateformes', label: 'Plateformes', icon: <IconDatabase />, href: '/admin/plateformes' }, { id: 'plateformes', label: 'Plateformes', icon: <IconDatabase />, href: '/admin/plateformes' },
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' }, { id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
{ id: 'institutions', label: 'Institutions financières', icon: <IconBank />, href: '/admin/institutions' }, { id: 'institutions', label: 'Institutions financières', icon: <IconBank />, href: '/admin/institutions' },
{ id: 'enveloppes', label: 'Enveloppes & supports', icon: <IconWallet />, href: '/admin/enveloppes' },
], ],
}, },
{ {
+781
View File
@@ -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 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>;
}
function IconLayers() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>;
}
function IconTag() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20.59 13.41L13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>;
}
function IconEdit() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
}
function IconTrash() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>;
}
function IconChevronLeft() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>;
}
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 (
<Modal
open
title={isNew ? 'Ajouter une enveloppe' : `Modifier — ${row.nom}`}
onClose={onClose}
width={600}
footer={
<>
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
<button type="submit" form="enveloppe-form" className="primary" disabled={saving}>
{saving ? '…' : (isNew ? 'Créer' : 'Enregistrer')}
</button>
</>
}
>
<form id="enveloppe-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{err && <div className="error">{err}</div>}
<div>
<label>Nom *</label>
<input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Livret A, Assurance-vie, PEA…" />
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<label>Plafond (€)</label>
<input value={form.plafond_eur} onChange={e => set('plafond_eur', e.target.value)} placeholder="Ex : 22950 — laisser vide si sans plafond" />
</div>
<div style={{ flex: 1 }}>
<label>Régime fiscal</label>
<input value={form.regime_fiscal} onChange={e => set('regime_fiscal', e.target.value)} placeholder="Ex : Exonéré IR/PS, Flat tax 30%…" />
</div>
</div>
<div>
<label>Catégories</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{categories.length === 0 && <span className="text-muted" style={{ fontSize: 12 }}>Aucune catégorie — gérez-les depuis l'onglet "Catégories".</span>}
{categories.map(c => {
const checked = (form.categories_ids || []).includes(c.id);
return (
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => set('categories_ids', e.target.checked
? [...(form.categories_ids || []), c.id]
: (form.categories_ids || []).filter(id => id !== c.id))} />
<span className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>
</label>
);
})}
</div>
</div>
<div>
<label>Supports disponibles</label>
<p style={{ margin: '0 0 6px', fontSize: 11, color: 'var(--text-muted)' }}>
Laisser vide pour une enveloppe "cash" sans choix de support (Livret A, Compte courant…).
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{supports.length === 0 && <span className="text-muted" style={{ fontSize: 12 }}>Aucun support — gérez-les depuis l'onglet "Supports".</span>}
{supports.map(s => {
const checked = (form.supports_ids || []).includes(s.id);
return (
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => set('supports_ids', e.target.checked
? [...(form.supports_ids || []), s.id]
: (form.supports_ids || []).filter(id => id !== s.id))} />
<span className="chip-cat" style={{ fontSize: 11 }}>{s.nom}</span>
</label>
);
})}
</div>
</div>
<div>
<label>Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
style={{ minHeight: 90, resize: 'vertical' }} placeholder="Informations générales sur l'enveloppe…" />
</div>
</form>
</Modal>
);
}
/* ── Section principale : liste des enveloppes ───────────────────── */
function EnveloppesSection() {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
const [search, setSearch] = useState('');
const [filterCategorie, setFilterCategorie] = useState('');
const allCategories = useMemo(() => {
const set = new Set();
rows.forEach(r => (r.categories || []).forEach(c => set.add(c.nom)));
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [rows]);
const filteredRows = useMemo(() => {
let r = rows;
if (filterCategorie) r = r.filter(x => (x.categories || []).some(c => c.nom === filterCategorie));
if (search.trim()) {
const q = search.trim().toLowerCase();
r = r.filter(x => x.nom.toLowerCase().includes(q));
}
return r;
}, [rows, search, filterCategorie]);
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
usePagination(filteredRows, 'cl_pagesize_ref_enveloppes', [search, filterCategorie]);
const load = useCallback(async () => {
try { setLoading(true); setRows(await api.get('/enveloppes-referentiel')); }
catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!openMenu) return;
const close = () => setOpenMenu(null);
window.addEventListener('scroll', close, true);
return () => window.removeEventListener('scroll', close, true);
}, [openMenu]);
const handleDelete = (row) => {
setDeleting({
title: 'Supprimer cette enveloppe ?',
message: `Supprimer "${row.nom}" du référentiel ? Cette action est définitive.`,
confirmLabel: 'Supprimer', danger: true,
onConfirm: async () => {
try { await api.del(`/enveloppes-referentiel/${row.id}`); load(); }
catch (e) { setErr(e.message); }
finally { setDeleting(null); }
},
});
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>Référentiel des enveloppes</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Livret A, Assurance-vie, PEA, PER… — catalogue commun, transverse à tous les espaces de travail.
</p>
</div>
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
<input
type="search"
placeholder="Rechercher par nom…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={filterCategorie}
onChange={e => setFilterCategorie(e.target.value)}
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
>
<option value="">Toutes les catégories</option>
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{(search || filterCategorie) && (
<button
onClick={() => { setSearch(''); setFilterCategorie(''); }}
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
>Effacer les filtres</button>
)}
</div>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Plafond</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Régime fiscal</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Supports</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
{rows.length === 0
? 'Aucune enveloppe dans le référentiel. Créez-en une pour commencer.'
: 'Aucun résultat pour ces critères.'}
</td></tr>
) : pagedItems.map((row, i) => (
<tr key={row.id}
style={{ borderBottom: i < pagedItems.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}
onClick={() => setEditing(row)}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<td style={{ padding: '10px 16px' }}>
<div style={{ fontWeight: 600 }}>{row.nom}</div>
{row.description && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{row.description}</div>}
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>{eur(row.plafond_eur)}</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>{row.regime_fiscal || '—'}</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.categories || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{(row.categories || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories.length - 2}</span>}
{(row.categories || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{(row.supports || []).length === 0 ? 'Cash (aucun)' : `${row.supports.length} support${row.supports.length > 1 ? 's' : ''}`}
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setOpenMenu({ row, x: rect.right, y: rect.bottom }); }}
>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination
page={page} setPage={setPage}
pageSize={pageSize} setPageSize={setPageSize}
totalPages={totalPages} totalItems={totalItems}
PAGE_SIZES={PAGE_SIZES}
/>
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y,
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
{[
{ icon: <IconEdit />, label: 'Modifier', onClick: () => { setOpenMenu(null); setEditing(openMenu.row); } },
{ icon: <IconTrash />, label: 'Supprimer', onClick: () => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }, color: 'var(--danger)' },
].map(({ icon, label, onClick, color }) => (
<button key={label}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
{label}
</button>
))}
</div>
</>
)}
{editing && <EnveloppeModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
</div>
);
}
/* ── Modale de création/édition d'un support ─────────────────────── */
function SupportModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
nom: row?.nom || '',
description: row?.description || '',
});
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; }
setSaving(true); setErr(null);
try {
const payload = { nom: form.nom.trim(), description: form.description.trim() || null };
if (isNew) await api.post('/supports-referentiel', payload);
else await api.put(`/supports-referentiel/${row.id}`, payload);
onSaved(); onClose();
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
return (
<Modal
open
title={isNew ? 'Ajouter un support' : `Modifier — ${row.nom}`}
onClose={onClose}
width={480}
footer={
<>
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
<button type="submit" form="support-form" className="primary" disabled={saving}>
{saving ? '…' : (isNew ? 'Créer' : 'Enregistrer')}
</button>
</>
}
>
<form id="support-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{err && <div className="error">{err}</div>}
<div>
<label>Nom *</label>
<input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Fonds euros, ETF, SCPI…" />
</div>
<div>
<label>Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
style={{ minHeight: 90, resize: 'vertical' }} placeholder="Informations générales sur ce support…" />
</div>
</form>
</Modal>
);
}
/* ── Section : supports d'investissement ─────────────────────────── */
function SupportsSection() {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
const load = useCallback(async () => {
try { setLoading(true); setRows(await api.get('/supports-referentiel')); }
catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!openMenu) return;
const close = () => setOpenMenu(null);
window.addEventListener('scroll', close, true);
return () => window.removeEventListener('scroll', close, true);
}, [openMenu]);
const handleDelete = (row) => {
setDeleting({
title: 'Supprimer ce support ?',
message: row.nb_enveloppes > 0
? `Supprimer "${row.nom}" ? Il sera retiré des ${row.nb_enveloppes} enveloppe(s) qui le proposent. Cette action est définitive.`
: `Supprimer "${row.nom}" du référentiel ? Cette action est définitive.`,
confirmLabel: 'Supprimer', danger: true,
onConfirm: async () => {
try { await api.del(`/supports-referentiel/${row.id}`); load(); }
catch (e) { setErr(e.message); }
finally { setDeleting(null); }
},
});
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>Référentiel des supports</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Fonds euros, OPCVM, ETF, SCPI, titres vifs… — supports d'investissement disponibles au sein des enveloppes.
</p>
</div>
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Enveloppes</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={3} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Aucun support dans le référentiel. Créez-en un pour commencer.</td></tr>
) : rows.map((row, i) => (
<tr key={row.id}
style={{ borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}
onClick={() => setEditing(row)}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<td style={{ padding: '10px 16px' }}>
<div style={{ fontWeight: 600 }}>{row.nom}</div>
{row.description && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{row.description}</div>}
</td>
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
<span style={{ display: 'inline-block', minWidth: 28, padding: '2px 8px', borderRadius: 12, fontSize: 12, fontWeight: 700,
background: row.nb_enveloppes > 0 ? 'rgba(99,102,241,.12)' : 'var(--surface-2)',
color: row.nb_enveloppes > 0 ? '#6366f1' : 'var(--text-muted)' }}>
{row.nb_enveloppes}
</span>
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setOpenMenu({ row, x: rect.right, y: rect.bottom }); }}
>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y,
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
{[
{ icon: <IconEdit />, label: 'Modifier', onClick: () => { setOpenMenu(null); setEditing(openMenu.row); } },
{ icon: <IconTrash />, label: 'Supprimer', onClick: () => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }, color: 'var(--danger)' },
].map(({ icon, label, onClick, color }) => (
<button key={label}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
{label}
</button>
))}
</div>
</>
)}
{editing && <SupportModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
</div>
);
}
/* ── Section : catégories (familles) ──────────────────────────────
Reprise quasi à l'identique de CategoriesInstitutionsSection (AdminInstitutions.jsx),
pointée sur /ref-categories-enveloppes. */
function CategoriesEnveloppesSection() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [newNom, setNewNom] = useState('');
const [adding, setAdding] = useState(false);
const [editId, setEditId] = useState(null);
const [editNom, setEditNom] = useState('');
const [saving, setSaving] = useState(false);
const [openMenu, setOpenMenu] = useState(null);
const [confirm, setConfirm] = useState(null);
const apiPath = '/ref-categories-enveloppes';
const load = useCallback(() => {
setLoading(true);
api.get(apiPath)
.then(data => { setItems(data); setErr(null); })
.catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!openMenu) return;
const close = () => setOpenMenu(null);
window.addEventListener('scroll', close, true);
return () => window.removeEventListener('scroll', close, true);
}, [openMenu]);
const handleAdd = async (e) => {
e.preventDefault();
if (!newNom.trim()) return;
setAdding(true); setErr(null);
try { await api.post(apiPath, { nom: newNom.trim() }); setNewNom(''); load(); }
catch (ex) { setErr(ex.message); }
finally { setAdding(false); }
};
const startEdit = (item) => { setEditId(item.id); setEditNom(item.nom); };
const cancelEdit = () => { setEditId(null); setEditNom(''); };
const handleRename = async (e, id) => {
e.preventDefault();
if (!editNom.trim()) return;
setSaving(true); setErr(null);
try { await api.put(`${apiPath}/${id}`, { nom: editNom.trim() }); cancelEdit(); load(); }
catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
const handleDelete = (item) => {
setConfirm({
title: 'Confirmer la suppression',
message: `Supprimer "${item.nom}" ?`,
confirmLabel: 'Supprimer',
onConfirm: async () => {
setConfirm(null);
try { await api.del(`${apiPath}/${item.id}`); load(); }
catch (ex) { setErr(ex.message); }
},
});
};
const openContextMenu = (e, item) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ item, x: rect.right - 160, y: rect.bottom + 4 });
};
return (
<div>
<div style={{ marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Catégories (familles)</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Liste globale utilisée pour classer les enveloppes du référentiel (Épargne réglementée, Assurance-vie, Épargne retraite…).
</p>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
<div className="card" style={{ marginBottom: 16, padding: '16px 20px' }}>
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
value={newNom}
onChange={e => setNewNom(e.target.value)}
placeholder="Nouvelle catégorie…"
style={{ flex: 1, padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}
/>
<button type="submit" className="primary" disabled={adding || !newNom.trim()}>{adding ? '…' : 'Ajouter'}</button>
</form>
</div>
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Utilisée</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr><td colSpan={3} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Aucune catégorie.</td></tr>
) : items.map((item, i) => (
<tr key={item.id} style={{ borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px' }}>
{editId === item.id ? (
<form onSubmit={e => handleRename(e, item.id)} style={{ display: 'flex', gap: 6 }}>
<input autoFocus value={editNom} onChange={e => setEditNom(e.target.value)}
style={{ flex: 1, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }} />
<button type="submit" className="primary" disabled={saving} style={{ fontSize: 12, padding: '5px 10px' }}>OK</button>
<button type="button" onClick={cancelEdit} style={{ fontSize: 12, padding: '5px 10px' }}>Annuler</button>
</form>
) : <span style={{ fontWeight: 600 }}>{item.nom}</span>}
</td>
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
<span style={{ display: 'inline-block', minWidth: 28, padding: '2px 8px', borderRadius: 12, fontSize: 12, fontWeight: 700,
background: item.nb_utilises > 0 ? 'rgba(99,102,241,.12)' : 'var(--surface-2)',
color: item.nb_utilises > 0 ? '#6366f1' : 'var(--text-muted)' }}>
{item.nb_utilises}
</span>
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onClick={e => openContextMenu(e, item)}
>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { const it = openMenu.item; setOpenMenu(null); startEdit(it); }}>
<IconEdit /> Renommer
</button>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { const it = openMenu.item; setOpenMenu(null); handleDelete(it); }}>
<IconTrash /> Supprimer
</button>
</div>
</>
)}
<ConfirmModal open={!!confirm} title={confirm?.title} message={confirm?.message} confirmLabel={confirm?.confirmLabel}
onConfirm={confirm?.onConfirm} onCancel={() => setConfirm(null)} />
</div>
);
}
const NAV = [
{ id: 'enveloppes', label: 'Enveloppes', icon: <IconWallet /> },
{ id: 'supports', label: 'Supports', icon: <IconLayers /> },
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
];
/* ── Page principale ──────────────────────────────────────────── */
export default function AdminEnveloppes() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'enveloppes';
return (
<div className="account-layout">
<aside className="account-sidebar">
<div style={{ marginBottom: 4 }}>
<button
onClick={() => navigate('/admin')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12,
color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer',
padding: '4px 0', marginBottom: 8 }}>
<IconChevronLeft /> Administration
</button>
</div>
<h1 className="account-title">Enveloppes & supports</h1>
<div className="account-nav-group">
{NAV.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => navigate(`/admin/enveloppes?section=${item.id}`, { replace: true })}
>
{item.icon}
{item.label}
</button>
))}
</div>
</aside>
<div className="account-content">
{section === 'enveloppes' && <EnveloppesSection />}
{section === 'supports' && <SupportsSection />}
{section === 'categories' && <CategoriesEnveloppesSection />}
</div>
</div>
);
}
+132 -1
View File
@@ -203,6 +203,118 @@ function InstitutionModal({ row, onClose, onSaved }) {
); );
} }
/* ── Recherche IA globale (demande Olivier 28/09/26) ──────────────
Applique la même route POST /institutions-referentiel/ai-lookup que la baguette magique de
InstitutionModal, mais en boucle séquentielle sur toutes les institutions "incomplètes"
(sans description et/ou sans catégorie). Choix de conception validés par Olivier :
- Périmètre : seulement les institutions incomplètes, pas tout le référentiel (évite de
relancer inutilement des appels IA — donc des tokens — sur ce qui est déjà complété).
- Écrasement : jamais — seuls les champs actuellement VIDES sont complétés (description, URL,
catégories). La Domiciliation n'est donc jamais touchée par cette action, même si elle porte
encore la valeur par défaut 'FR' : elle a toujours une valeur, on ne peut pas distinguer un
'FR' par défaut d'un 'FR' vérifié à la main.
Pas de nouvelle route backend : orchestration entièrement côté client (boucle séquentielle,
un appel ai-lookup puis un PUT par institution), sur le modèle de ComptesOrphelinsSection
(pas d'infrastructure de job en arrière-plan dans ce projet). */
const AI_STATUS_LABEL = { pending: 'En attente', running: 'En cours…', ok: 'Mis à jour', error: 'Échec', skipped: 'Ignoré' };
function GlobalAiLookupModal({ targets, onClose, onDone }) {
const [items, setItems] = useState(() => targets.map(t => ({ ...t, status: 'pending', error: null })));
const [phase, setPhase] = useState('confirm'); // 'confirm' | 'running' | 'done'
const stopRef = useRef(false);
const updateItem = (idx, patch) => setItems(prev => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
const run = async () => {
setPhase('running');
stopRef.current = false;
for (let i = 0; i < items.length; i++) {
if (stopRef.current) { updateItem(i, { status: 'skipped' }); continue; }
updateItem(i, { status: 'running' });
const inst = items[i];
try {
const r = await api.post('/institutions-referentiel/ai-lookup', { nom: inst.nom, url: inst.url || undefined });
if (!r.ok) { updateItem(i, { status: 'error', error: r.error }); continue; }
const payload = {
nom: inst.nom,
url: inst.url || r.url || null,
domiciliation: inst.domiciliation,
description: (inst.description && inst.description.trim()) ? inst.description : (r.description || null),
categories_ids: (inst.categories && inst.categories.length) ? inst.categories.map(c => c.id) : (r.categories_ids || []),
};
await api.put(`/institutions-referentiel/${inst.id}`, payload);
updateItem(i, { status: 'ok' });
} catch (ex) {
updateItem(i, { status: 'error', error: ex.message });
}
}
setPhase('done');
};
const okCount = items.filter(it => it.status === 'ok').length;
const errorCount = items.filter(it => it.status === 'error').length;
return (
<Modal open title="Recherche IA globale" onClose={onClose} busy={phase === 'running'} width={560}
footer={
phase === 'confirm' ? (
<>
<button type="button" onClick={onClose}>Annuler</button>
<button type="button" className="primary" onClick={run} disabled={items.length === 0}>Lancer ({items.length})</button>
</>
) : phase === 'running' ? (
<button type="button" onClick={() => { stopRef.current = true; }}>Arrêter après l'institution en cours</button>
) : (
<button type="button" className="primary" onClick={() => { onDone(); onClose(); }}>Fermer</button>
)
}
>
{phase === 'confirm' && (
items.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>Toutes les institutions du référentiel sont déjà complètes (description et catégories renseignées).</p>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 0 }}>
{items.length} institution{items.length > 1 ? 's' : ''} sans description et/ou sans catégorie {items.length > 1 ? 'seront recherchées' : 'sera recherchée'} sur le web via l'Assistant IA.
Seuls les champs vides sont complétés (description, URL, catégories) — un champ déjà renseigné, y compris la Domiciliation, n'est jamais modifié.
</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none', maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6 }}>
{items.map(it => (
<li key={it.id} style={{ padding: '6px 12px', fontSize: 13, borderBottom: '1px solid var(--border)' }}>{it.nom}</li>
))}
</ul>
</>
)
)}
{(phase === 'running' || phase === 'done') && (
<>
{phase === 'done' && (
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 0 }}>
Terminé — {okCount} mise{okCount > 1 ? 's' : ''} à jour, {errorCount} échec{errorCount > 1 ? 's' : ''}.
</p>
)}
<ul style={{ margin: 0, padding: 0, listStyle: 'none', maxHeight: 320, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6 }}>
{items.map(it => (
<li key={it.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '6px 12px', fontSize: 13, borderBottom: '1px solid var(--border)' }}>
<span>{it.nom}</span>
<span
title={it.error || ''}
style={{
fontSize: 11, fontWeight: 600, flexShrink: 0,
color: it.status === 'ok' ? '#16a34a' : it.status === 'error' ? '#dc2626' : it.status === 'running' ? '#b45309' : 'var(--text-muted)',
}}
>
{AI_STATUS_LABEL[it.status] || it.status}
</span>
</li>
))}
</ul>
</>
)}
</Modal>
);
}
/* ── Section principale : liste des institutions ─────────────────── */ /* ── Section principale : liste des institutions ─────────────────── */
function InstitutionsSection() { function InstitutionsSection() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -214,6 +326,8 @@ function InstitutionsSection() {
const [openMenu, setOpenMenu] = useState(null); const [openMenu, setOpenMenu] = useState(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filterCategorie, setFilterCategorie] = useState(''); const [filterCategorie, setFilterCategorie] = useState('');
const [aiAvailable, setAiAvailable] = useState(false);
const [globalAi, setGlobalAi] = useState(null); // null | { targets: [...] }
const allCategories = useMemo(() => { const allCategories = useMemo(() => {
const set = new Set(); const set = new Set();
@@ -242,6 +356,10 @@ function InstitutionsSection() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => {
api.get('/admin/ai/config').then(cfg => setAiAvailable(!!cfg?.actif)).catch(() => setAiAvailable(false));
}, []);
useEffect(() => { useEffect(() => {
if (!openMenu) return; if (!openMenu) return;
const close = () => setOpenMenu(null); const close = () => setOpenMenu(null);
@@ -249,6 +367,11 @@ function InstitutionsSection() {
return () => window.removeEventListener('scroll', close, true); return () => window.removeEventListener('scroll', close, true);
}, [openMenu]); }, [openMenu]);
const openGlobalAiLookup = () => {
const targets = rows.filter(r => !(r.description && r.description.trim()) || !(r.categories && r.categories.length));
setGlobalAi({ targets });
};
const handleDelete = (row) => { const handleDelete = (row) => {
setDeleting({ setDeleting({
title: 'Supprimer cette institution ?', title: 'Supprimer cette institution ?',
@@ -273,7 +396,14 @@ function InstitutionsSection() {
Banques, courtiers, assureurs… — catalogue commun, transverse à tous les espaces de travail. Banques, courtiers, assureurs… — catalogue commun, transverse à tous les espaces de travail.
</p> </p>
</div> </div>
<button className="primary" style={{ flexShrink: 0 }} onClick={() => setEditing('new')}>+ Ajouter</button> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{aiAvailable && (
<button style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} onClick={openGlobalAiLookup} title="Compléter les institutions incomplètes via l'Assistant IA">
<IconWand /> Recherche IA globale
</button>
)}
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
</div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
@@ -402,6 +532,7 @@ function InstitutionsSection() {
{editing && <InstitutionModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />} {editing && <InstitutionModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />} {deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
{globalAi && <GlobalAiLookupModal targets={globalAi.targets} onClose={() => setGlobalAi(null)} onDone={load} />}
</div> </div>
); );
} }