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

+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;
+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 institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
import enveloppesReferentielRouter from './routes/enveloppesReferentiel.js';
import supportsReferentielRouter from './routes/supportsReferentiel.js';
import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js';
import refCategoriesRouter from './routes/ref-categories.js';
import refSecteursRouter from './routes/ref-secteurs.js';
import categoriesInvRouter from './routes/categories-inv.js';
@@ -176,6 +179,12 @@ app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter);
app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter);
app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter);
// Chantier "Référentiel Enveloppes & Supports d'épargne" (28/09/26, demande Olivier) —
// catalogue admin transverse aux workspaces, catalogue seul pour l'instant (pas de
// rattachement à l'existant), cf. enveloppesReferentiel.js.
app.use('/api/enveloppes-referentiel', requireAuth, requireAdmin, enveloppesReferentielRouter);
app.use('/api/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter);
app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter);
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
app.use('/api/categories-inv', requireAuth, categoriesInvRouter);