Files
crowdlending-app/backend/src/db/index.js
T
2026-09-28 08:24:16 +02:00

4208 lines
242 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateSimul } from '../utils/schedule.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH || path.resolve(__dirname, '../../data/crowdlending.db');
// Ensure data directory exists
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
// ── Pending restore (déclenché par POST /api/admin/exports/:filename/restore) ─
// Si un fichier .pending-restore existe, on l'applique avant d'ouvrir la DB.
const PENDING_RESTORE = DB_PATH + '.pending-restore';
// Tables locales à CET environnement, à préserver à travers une restauration
// (cf. feedback_restore_preserve_env_local.md) : une restauration remplace le
// fichier DB dans son intégralité, y compris ces tables — sans ce mécanisme,
// restaurer un export prod dans dev invalide silencieusement les clés API
// générées côté dev (api_keys, y compris les clés internes auto-provisionnées
// pour l'assistant IA), écrase la configuration des fournisseurs IA propres
// à dev (ai_providers/ai_config) par celle de la source restaurée, et
// écraserait de la même façon l'étiquette d'environnement (parametres_environnement)
// et le thème de couleur (parametres_theme — Admin > Général, cf. 05/09/26)
// de cette instance par ceux de la source restaurée. Ordre important :
// ai_config référence ai_providers (FK provider_defaut_id).
const ENV_LOCAL_TABLES = ['api_keys', 'ai_providers', 'ai_config', 'parametres_environnement', 'parametres_theme'];
let pendingRestorePreserve = null;
if (fs.existsSync(PENDING_RESTORE) && fs.existsSync(DB_PATH)) {
try {
const oldDb = new Database(DB_PATH, { readonly: true });
const existingTables = new Set(
oldDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name)
);
pendingRestorePreserve = {};
for (const table of ENV_LOCAL_TABLES) {
if (existingTables.has(table)) {
pendingRestorePreserve[table] = oldDb.prepare(`SELECT * FROM ${table}`).all();
}
}
oldDb.close();
} catch (e) {
console.error('[restore] Échec de la sauvegarde des tables locales (api_keys/ai_providers/ai_config/parametres_environnement/parametres_theme) avant restauration :', e.message);
pendingRestorePreserve = null;
}
}
if (fs.existsSync(PENDING_RESTORE)) {
try {
fs.copyFileSync(PENDING_RESTORE, DB_PATH);
// Supprimer les fichiers WAL/SHM de l'ancienne DB — s'ils persistent,
// SQLite les applique sur la nouvelle base et provoque SQLITE_CORRUPT.
for (const suffix of ['-wal', '-shm']) {
const f = DB_PATH + suffix;
if (fs.existsSync(f)) fs.unlinkSync(f);
}
fs.unlinkSync(PENDING_RESTORE);
console.log('[restore] Base de données restaurée avec succès.');
} catch (e) {
console.error('[restore] Échec de la restauration :', e.message);
}
}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// ── CLEANUP PRIORITAIRE : tables __repair_* orphelines ───────────────────
// Si un repair précédent a créé des tables __repair_X mais n'a pas pu
// finaliser le RENAME (interruption, crash), on le finit ici, avant tout.
{
const repairTables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '__repair_%'"
).all().map(r => r.name);
if (repairTables.length > 0) {
db.exec('PRAGMA foreign_keys = OFF');
// SQLite valide toutes les vues lors d'un RENAME TABLE.
// Les vues cassées (qui référencent _investissements_old ou des tables
// provisoirement absentes) doivent être supprimées avant les renommages.
db.exec('DROP VIEW IF EXISTS v_interets_annuels');
db.exec('DROP VIEW IF EXISTS v_synthese_inv');
for (const repairName of repairTables) {
const originalName = repairName.slice('__repair_'.length);
const originalExists = db.prepare(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name=?"
).get(originalName).n > 0;
if (originalExists) {
// Les deux coexistent : l'original est vide, le repair a les données.
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL"
).all(originalName);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec(`DROP TABLE "${originalName}"`);
}
db.exec(`ALTER TABLE "${repairName}" RENAME TO "${originalName}"`);
}
// Recréer les index (idempotent IF NOT EXISTS)
const knownIndexes = [
'CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)',
'CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)',
'CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)',
'CREATE INDEX IF NOT EXISTS idx_simul_inv ON simul_remboursements(investissement_id)',
'CREATE INDEX IF NOT EXISTS idx_simul_date ON simul_remboursements(date_prevue)',
'CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)',
'CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)',
];
for (const sql of knownIndexes) db.exec(sql);
// Recréer les vues avec les références correctes
db.exec(`CREATE VIEW IF NOT EXISTS v_interets_annuels AS
SELECT
i.investisseur_id,
substr(r.date_remb,1,4) AS annee,
SUM(r.interets_bruts) AS interets_bruts,
SUM(r.prelev_sociaux) AS prelev_sociaux,
SUM(r.prelev_forfaitaire) AS prelev_forfaitaire,
SUM(r.net_recu) AS net_recu
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE r.statut IN ('paye','partiel')
GROUP BY i.investisseur_id, substr(r.date_remb,1,4)`);
db.exec(`CREATE VIEW IF NOT EXISTS v_synthese_inv AS
SELECT
i.investisseur_id,
COUNT(*) AS nb_projets,
SUM(i.montant_investi) AS total_investi,
SUM(CASE WHEN i.statut='en_cours' THEN i.montant_investi ELSE 0 END) AS encours,
SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi ELSE 0 END) AS rembourse,
SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN i.montant_investi ELSE 0 END) AS en_defaut
FROM investissements i
GROUP BY i.investisseur_id`);
db.exec('PRAGMA foreign_keys = ON');
}
}
// Apply schema on first run (idempotent thanks to IF NOT EXISTS)
const schemaSql = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schemaSql);
// ── RÉPARATION D'URGENCE (doit s'exécuter EN PREMIER) ────────────────────
// SQLite 3.26+ a auto-mis-à-jour les FK des tables enfants lors d'un RENAME
// TABLE de investissements → _investissements_old. Après DROP TABLE orpheline,
// ces FK sont invalides. writable_schema est bloqué par better-sqlite3, donc
// on recrée chaque table cassée : DDL corrigé en JS → temp table → copy → rename.
{
const hasOrphanTable = db.prepare(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE name='_investissements_old'"
).get().n > 0;
const brokenTables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE '%_investissements_old%'"
).all().map(r => r.name);
if (hasOrphanTable || brokenTables.length > 0) {
db.exec('PRAGMA foreign_keys = OFF');
if (hasOrphanTable) {
db.exec('DROP TABLE IF EXISTS _investissements_old');
}
for (const tableName of brokenTables) {
const row = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?"
).get(tableName);
if (!row) continue;
// Corriger la référence FK cassée en JS (writable_schema indisponible)
const tempName = `__repair_${tableName}`;
const fixedDdl = row.sql
.replace(/_investissements_old/g, 'investissements')
.replace(new RegExp(`CREATE TABLE (?:"${tableName}"|${tableName})\\b`), `CREATE TABLE "${tempName}"`);
// Colonnes réelles de la table cassée (pour le INSERT SELECT)
const cols = db.prepare(`PRAGMA table_info("${tableName}")`)
.all().map(c => `"${c.name}"`).join(', ');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${cols}) SELECT ${cols} FROM "${tableName}"`);
// Supprimer les index utilisateurs (ils seront recréés ci-dessous)
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL"
).all(tableName);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec(`DROP TABLE "${tableName}"`);
db.exec(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`);
}
// Recréer les index supprimés avec leurs tables
if (brokenTables.includes('remboursements')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)');
}
if (brokenTables.includes('simul_remboursements')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_simul_inv ON simul_remboursements(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_simul_date ON simul_remboursements(date_prevue)');
}
db.exec('PRAGMA foreign_keys = ON');
}
// Corriger aussi les VUES dont le corps SQL référence _investissements_old
// (SQLite 3.26+ auto-met-à-jour les JOIN dans les vues lors d'un RENAME TABLE)
const brokenViews = db.prepare(
"SELECT name, sql FROM sqlite_master WHERE type='view' AND sql LIKE '%_investissements_old%'"
).all();
for (const view of brokenViews) {
const fixedSql = view.sql.replace(/_investissements_old/g, 'investissements');
db.exec(`DROP VIEW IF EXISTS "${view.name}"`);
db.exec(fixedSql);
}
}
// ── Migrations incrémentales (colonnes ajoutées après le schéma initial) ──
const invCols = db.prepare("PRAGMA table_info(investisseurs)").all().map(c => c.name);
if (!invCols.includes('prenom')) {
db.exec('ALTER TABLE investisseurs ADD COLUMN prenom TEXT');
}
if (!invCols.includes('type')) {
db.exec("ALTER TABLE investisseurs ADD COLUMN type TEXT NOT NULL DEFAULT 'famille'");
// Backfill : PM / SCI / SCPI → entreprise, tout le reste → famille
db.exec("UPDATE investisseurs SET type = 'entreprise' WHERE type_fiscal IN ('PM','SCI','SCPI')");
}
// ── Migration : suppression de la colonne autres_taxes (devenue inutile) ──
const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (rembCols.includes('autres_taxes')) {
// SQLite exige que la vue qui référence la colonne soit supprimée avant DROP COLUMN
db.exec('DROP VIEW IF EXISTS v_interets_annuels');
db.exec('ALTER TABLE remboursements DROP COLUMN autres_taxes');
// Recrée la vue sans autres_taxes (version à jour du schéma)
db.exec(`
CREATE VIEW IF NOT EXISTS v_interets_annuels AS
SELECT
i.investisseur_id,
substr(r.date_remb,1,4) AS annee,
SUM(r.interets_bruts) AS interets_bruts,
SUM(r.prelev_sociaux) AS prelev_sociaux,
SUM(r.prelev_forfaitaire) AS prelev_forfaitaire,
SUM(r.net_recu) AS net_recu
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE r.statut IN ('paye','partiel')
GROUP BY i.investisseur_id, substr(r.date_remb,1,4)
`);
}
// ── Migration : renommage date_debut → date_premiere_echeance, date_echeance → date_cible ──
//
// ATTENTION (bug corrigé le 12/07/2026) : le bloc de "correction de formule" ci-dessous
// (SET date_cible = date_premiere_echeance + (duree_mois-1) mois, SANS clause IS NULL)
// s'exécutait auparavant à CHAQUE démarrage du serveur, pour TOUS les investissements,
// quel que soit type_remb. Combiné à la migration suivante (qui recopie date_cible dans
// date_premiere_echeance pour les prêts différés), cela créait une boucle : à chaque
// redémarrage, date_cible dérivait de +(duree_mois-1) mois supplémentaires, sans jamais
// être tracé dans investissement_historique. C'est la cause des dates aberrantes (parfois
// des siècles dans le futur) observées sur les prêts différés. La correction ne doit
// s'exécuter qu'UNE SEULE FOIS, au moment réel du renommage de colonne — jamais après.
{
const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
const migrationEnCours = cols.includes('date_debut') || cols.includes('date_echeance');
if (cols.includes('date_debut')) {
db.exec('ALTER TABLE investissements RENAME COLUMN date_debut TO date_premiere_echeance');
}
if (cols.includes('date_echeance')) {
db.exec('ALTER TABLE investissements RENAME COLUMN date_echeance TO date_cible');
}
// Backfill date_premiere_echeance = souscription + 1 mois si vide (idempotent : ne touche
// jamais une valeur déjà renseignée)
db.exec(`UPDATE investissements
SET date_premiere_echeance = date(date_souscription, '+1 month')
WHERE date_premiere_echeance IS NULL AND date_souscription IS NOT NULL`);
// Backfill date_cible = date_premiere_echeance + (duree_mois - 1) mois si vide (idempotent)
// Formule cohérente avec la simulation : échéance 1 = date_premiere_echeance, dernière = +duree-1 mois
db.exec(`UPDATE investissements
SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months')
WHERE date_cible IS NULL AND date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`);
// Correction ponctuelle des date_cible déjà calculées avec l'ancienne formule
// (+duree_mois au lieu de +duree_mois-1) — ne s'exécute que le jour du renommage effectif
// des colonnes (migrationEnCours), jamais à chaque démarrage.
if (migrationEnCours) {
db.exec(`UPDATE investissements
SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months')
WHERE date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`);
}
}
// ── Migration : type_remb 'mensuel' → 'amortissable' + ajout freq_interets ──
const invCols2 = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
db.exec("UPDATE investissements SET type_remb = 'amortissable' WHERE type_remb = 'mensuel'");
if (!invCols2.includes('freq_interets')) {
db.exec("ALTER TABLE investissements ADD COLUMN freq_interets TEXT NOT NULL DEFAULT 'mensuel'");
// Les prêts différés ont forcément une fréquence in_fine
db.exec("UPDATE investissements SET freq_interets = 'in_fine' WHERE type_remb = 'differe'");
}
// ── Migration : ajout cashback + interets_nets ────────────────────────
const rembCols2 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols2.includes('cashback')) {
db.exec('ALTER TABLE remboursements ADD COLUMN cashback REAL NOT NULL DEFAULT 0');
}
if (!rembCols2.includes('interets_nets')) {
db.exec('ALTER TABLE remboursements ADD COLUMN interets_nets REAL NOT NULL DEFAULT 0');
// Backfill depuis les colonnes existantes
db.exec('UPDATE remboursements SET interets_nets = ROUND(interets_bruts - prelev_sociaux - prelev_forfaitaire, 2)');
// Recalcule net_recu avec la nouvelle formule (cashback=0 sur les anciens, donc inchangé)
db.exec('UPDATE remboursements SET net_recu = ROUND(capital + cashback + interets_nets, 2)');
}
// ── Migration : prêts différés — date_premiere_echeance doit égaler date_cible ──
// (versement unique à l'échéance : les deux dates sont identiques)
{
const aCorriger = db.prepare(`
SELECT id, nom_projet, date_premiere_echeance, date_cible
FROM investissements
WHERE type_remb = 'differe'
AND date_cible IS NOT NULL
AND (date_premiere_echeance IS NULL OR date_premiere_echeance != date_cible)
`).all();
if (aCorriger.length > 0) {
const updateStmt = db.prepare(`
UPDATE investissements SET date_premiere_echeance = date_cible WHERE id = ?
`);
const histStmt = db.prepare(`
INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes)
VALUES (?, 'correction_auto_echeancier', ?, ?)
`);
for (const inv of aCorriger) {
updateStmt.run(inv.id);
histStmt.run(
inv.id,
JSON.stringify([{
champ: 'date_premiere_echeance',
label: 'Date 1ère échéance',
ancienne_valeur: inv.date_premiere_echeance,
nouvelle_valeur: inv.date_cible,
}]),
"Migration au démarrage : synchronisation avec date_cible (prêt différé — les deux dates doivent être identiques)",
);
}
// Régénère les simulations avec la nouvelle logique (date = startDate directement)
const differeInvs = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_souscription
FROM investissements
WHERE type_remb = 'differe'
`).all();
for (const inv of differeInvs) {
generateSimul(db, inv);
}
}
}
// ── Migration : ajout date_debut_simul (restructuration de prêt) ─────
{
const invColsDs = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invColsDs.includes('date_debut_simul')) {
db.exec('ALTER TABLE investissements ADD COLUMN date_debut_simul TEXT');
}
}
// ── Migration : statut 'defaut' → 'en_retard' ────────────────────────
// writable_schema est bloqué par better-sqlite3 → on recrée la table
// investissements avec le bon CHECK constraint (même stratégie que le bloc
// d'urgence). PRAGMA legacy_alter_table = ON empêche SQLite 3.26+ de
// réécrire les FK des tables enfants lors du RENAME final.
{
const schemaInv = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'"
).get()?.sql ?? '';
if (schemaInv.includes("'defaut'")) {
const tempName = '__repair_investissements';
const fixedDdl = schemaInv
.replace(/CREATE TABLE investissements\b/, `CREATE TABLE "${tempName}"`)
.replace(/'defaut'/g, "'en_retard'");
const colDefs = db.prepare('PRAGMA table_info(investissements)').all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
// Convertit les éventuels statuts 'defaut' → 'en_retard' lors du SELECT
const selectList = colDefs.map(c =>
c.name === 'statut'
? `CASE WHEN statut = 'defaut' THEN 'en_retard' ELSE statut END AS "statut"`
: `"${c.name}"`
).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${selectList} FROM investissements`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE investissements');
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)');
}
// Corriger la vue v_synthese_inv si elle référence encore 'defaut'
const viewSql = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='view' AND name='v_synthese_inv'"
).get()?.sql ?? '';
if (viewSql.includes("'defaut'")) {
db.exec('DROP VIEW IF EXISTS v_synthese_inv');
db.exec(`CREATE VIEW v_synthese_inv AS
SELECT
i.investisseur_id,
COUNT(*) AS nb_projets,
SUM(i.montant_investi) AS total_investi,
SUM(CASE WHEN i.statut='en_cours' THEN i.montant_investi ELSE 0 END) AS encours,
SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi ELSE 0 END) AS rembourse,
SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN i.montant_investi ELSE 0 END) AS en_defaut
FROM investissements i
GROUP BY i.investisseur_id`);
}
// Migrer les données (idempotent : no-op si déjà fait ou aucun statut 'defaut')
db.exec("UPDATE investissements SET statut = 'en_retard' WHERE statut = 'defaut'");
}
// ── Migration : table historique des modifications d'investissement ──
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_historique (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
date_evenement TEXT NOT NULL DEFAULT (date('now')),
type_evenement TEXT NOT NULL DEFAULT 'modification',
changements TEXT NOT NULL DEFAULT '[]',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Migration : table des révisions de conditions de prêt (taux/date) ───
// Distincte de investissement_historique (qui est une piste d'audit générique
// auto-détectée sur tout changement de champ). Ici, on trace un événement métier
// explicite (retard projet, renégociation…) avec un motif obligatoire, et qui
// déclenche la régénération de l'échéancier (cf. routes/investissements.js).
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
date_effet TEXT NOT NULL,
ancien_taux REAL,
nouveau_taux REAL,
ancienne_date_cible TEXT,
nouvelle_date_cible TEXT,
motif TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_revisions_inv ON investissement_revisions(investissement_id)');
// ── Migration : traçage de la durée lors d'une révision de date cible ────
// nouvelle_date_cible ne suffisait pas à elle seule à modifier l'échéancier généré
// (generateSimul se base sur duree_mois, pas sur date_cible). Quand une révision change
// la date cible, duree_mois doit être recalculé en conséquence — on trace l'ancienne et
// la nouvelle valeur ici pour permettre un rollback fidèle (DELETE /revisions/:rid).
{
const revCols = db.prepare('PRAGMA table_info(investissement_revisions)').all().map(c => c.name);
if (!revCols.includes('ancien_duree_mois')) {
db.exec('ALTER TABLE investissement_revisions ADD COLUMN ancien_duree_mois INTEGER');
}
if (!revCols.includes('nouveau_duree_mois')) {
db.exec('ALTER TABLE investissement_revisions ADD COLUMN nouveau_duree_mois INTEGER');
}
}
// ── Migration : traçage du statut lors d'une révision (fonctionnalité Prolongation) ──
// Quand une révision change la date cible d'un dossier en_retard/procedure, le statut
// bascule automatiquement à 'prolongation' (cf. routes/investissements.js). On trace
// l'ancien et le nouveau statut ici, comme ancien_taux/nouveau_taux, pour permettre un
// rollback fidèle (DELETE /revisions/:rid restaure ancien_statut).
{
const revColsStatut = db.prepare('PRAGMA table_info(investissement_revisions)').all().map(c => c.name);
if (!revColsStatut.includes('ancien_statut')) {
db.exec('ALTER TABLE investissement_revisions ADD COLUMN ancien_statut TEXT');
}
if (!revColsStatut.includes('nouveau_statut')) {
db.exec('ALTER TABLE investissement_revisions ADD COLUMN nouveau_statut TEXT');
}
}
// ── Migration : rôle utilisateur ─────────────────────────────────────────
{
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!userCols.includes('role')) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
// Le premier utilisateur enregistré (id le plus petit) devient administrateur
db.exec("UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)");
}
}
// ── Migration : table de logs des jobs automatiques ──────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS job_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_name TEXT NOT NULL,
run_at TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'ok',
nb_changes INTEGER NOT NULL DEFAULT 0,
details TEXT,
error_msg TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_job_logs_run_at ON job_logs(run_at DESC)');
// ── Notation : critères par plateforme ───────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS notation_criteres (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'etoiles',
valeurs TEXT,
min_val REAL,
max_val REAL,
description TEXT,
ordre INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_notation_plat ON notation_criteres(plateforme_id, ordre)');
// ── Types de garanties ───────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS garantie_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
libelle TEXT NOT NULL,
description TEXT,
ordre INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_garantie_types_user ON garantie_types(user_id, ordre)');
// ── Migration : colonne is_principal sur investisseurs ───────────────────
{
const invColsPrincipal = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name);
if (!invColsPrincipal.includes('is_principal')) {
db.exec('ALTER TABLE investisseurs ADD COLUMN is_principal INTEGER NOT NULL DEFAULT 0');
// Backfill : le plus ancien investisseur de type 'famille' par user devient principal
db.exec(`
UPDATE investisseurs SET is_principal = 1
WHERE id IN (
SELECT MIN(id) FROM investisseurs WHERE type = 'famille' GROUP BY user_id
)
`);
}
}
// ── Migration : domiciliation / fiscalité sur plateformes ───────────────
{
const platCols = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platCols.includes('domiciliation')) {
db.exec("ALTER TABLE plateformes ADD COLUMN domiciliation TEXT NOT NULL DEFAULT 'france'");
}
if (!platCols.includes('fiscalite')) {
db.exec("ALTER TABLE plateformes ADD COLUMN fiscalite TEXT NOT NULL DEFAULT 'flat_tax'");
}
if (!platCols.includes('taux_fiscalite_locale')) {
db.exec('ALTER TABLE plateformes ADD COLUMN taux_fiscalite_locale REAL');
}
if (!platCols.includes('type_produit_fiscal')) {
db.exec("ALTER TABLE plateformes ADD COLUMN type_produit_fiscal TEXT NOT NULL DEFAULT '2TT'");
}
// ── Table plateforme_tax_details ──────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS plateforme_tax_details (
id INTEGER PRIMARY KEY,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
annee INTEGER NOT NULL,
raison_sociale TEXT,
siret_n TEXT,
siret_n1 TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(plateforme_id, annee)
)
`);
if (!platCols.includes('methode_remboursement')) {
db.exec("ALTER TABLE plateformes ADD COLUMN methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'");
}
}
// ── Migration : methode_remboursement sur remboursements ─────────────────────
{
const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols.includes('methode_remboursement')) {
db.exec("ALTER TABLE remboursements ADD COLUMN methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'");
}
}
// ── Migration : remboursement_id sur depots_retraits (retrait auto) ───────────
{
const drCols = db.prepare('PRAGMA table_info(depots_retraits)').all().map(c => c.name);
if (!drCols.includes('remboursement_id')) {
db.exec('ALTER TABLE depots_retraits ADD COLUMN remboursement_id INTEGER');
}
}
// ── Migration : détail des taux 2778-SD sur taux_pfu ─────────────────────────
// Décompose prelev_sociaux en ses 3 composantes pour le calcul de la déclaration
// mensuelle 2778-SD (taux identiques au PFU global mais ventilés par case).
// Taux 2021-2025 : CSG 9,2 % | CRDS 0,5 % | Solidarité 7,5 % → total 17,2 %
// Taux 2026+ : CSG 10,6 %| CRDS 0,5 % | Solidarité 7,5 % → total 18,6 %
{
const pfuCols = db.prepare('PRAGMA table_info(taux_pfu)').all().map(c => c.name);
if (!pfuCols.includes('csg')) {
db.exec('ALTER TABLE taux_pfu ADD COLUMN csg REAL NOT NULL DEFAULT 9.2');
// Mise à jour des années 2026+ où la CSG passe à 10,6 %
db.exec('UPDATE taux_pfu SET csg = 10.6 WHERE annee >= 2026');
}
if (!pfuCols.includes('crds')) {
db.exec('ALTER TABLE taux_pfu ADD COLUMN crds REAL NOT NULL DEFAULT 0.5');
}
if (!pfuCols.includes('solidarite')) {
db.exec('ALTER TABLE taux_pfu ADD COLUMN solidarite REAL NOT NULL DEFAULT 7.5');
}
}
// ── Seed taux_pfu si vide ─────────────────────────────────────────────
const pfuCount = db.prepare('SELECT COUNT(*) AS n FROM taux_pfu').get().n;
if (pfuCount === 0) {
const insertPfu = db.prepare(
'INSERT OR IGNORE INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux, csg, crds, solidarite) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
const seedPfu = db.transaction(() => {
const data = [
[2018, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2019, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2020, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2021, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2022, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2023, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2024, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2025, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5],
[2026, 31.4, 12.8, 18.6, 10.6, 0.5, 7.5],
];
for (const row of data) insertPfu.run(...row);
});
seedPfu();
}
// ── Migration : categorie_id sur investissements ──────────────────────────
{
const invColsCat = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invColsCat.includes('categorie_id')) {
db.exec('ALTER TABLE investissements ADD COLUMN categorie_id INTEGER REFERENCES categories_plateforme(id) ON DELETE SET NULL');
}
}
// ── Migration : remboursements — investissement_id nullable + type + bonus ──
// Recrée la table pour lever la contrainte NOT NULL sur investissement_id
// et ajouter les colonnes bonus_plateforme_id, bonus_investisseur_id, type.
{
const rembColsBonus = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembColsBonus.includes('bonus_plateforme_id')) {
const tempName = '__repair_remboursements';
db.exec('PRAGMA foreign_keys = OFF');
db.exec('DROP VIEW IF EXISTS v_interets_annuels');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(`CREATE TABLE "${tempName}" (
id INTEGER PRIMARY KEY,
investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE,
bonus_plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE SET NULL,
bonus_investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
type TEXT NOT NULL DEFAULT 'normal',
date_remb TEXT NOT NULL,
capital REAL NOT NULL DEFAULT 0,
interets_bruts REAL NOT NULL DEFAULT 0,
prelev_sociaux REAL NOT NULL DEFAULT 0,
prelev_forfaitaire REAL NOT NULL DEFAULT 0,
cashback REAL NOT NULL DEFAULT 0,
interets_nets REAL NOT NULL DEFAULT 0,
net_recu REAL NOT NULL DEFAULT 0,
statut TEXT NOT NULL DEFAULT 'paye'
CHECK(statut IN ('paye','retard','partiel','impaye')),
source TEXT NOT NULL DEFAULT 'manuel',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
// Copier toutes les colonnes existantes (investissement_id était NOT NULL)
const existingCols = rembColsBonus.map(c => `"${c}"`).join(', ');
db.exec(`INSERT INTO "${tempName}" (${existingCols}) SELECT ${existingCols} FROM remboursements`);
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='remboursements' AND sql IS NOT NULL"
).all();
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE remboursements');
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO remboursements`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)');
// Recréer la vue (exclut les bonus qui n'ont pas d'investissement_id)
db.exec(`CREATE VIEW IF NOT EXISTS v_interets_annuels AS
SELECT
i.investisseur_id,
substr(r.date_remb,1,4) AS annee,
SUM(r.interets_bruts) AS interets_bruts,
SUM(r.prelev_sociaux) AS prelev_sociaux,
SUM(r.prelev_forfaitaire) AS prelev_forfaitaire,
SUM(r.net_recu) AS net_recu
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE r.statut IN ('paye','partiel')
AND r.type = 'normal'
GROUP BY i.investisseur_id, substr(r.date_remb,1,4)`);
db.exec('PRAGMA foreign_keys = ON');
}
}
// ── Migration : categorie_id sur investissements ──────────────────────────
{
const invColsCat = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invColsCat.includes('categorie_id')) {
db.exec('ALTER TABLE investissements ADD COLUMN categorie_id INTEGER REFERENCES categories_plateforme(id) ON DELETE SET NULL');
}
}
// ── Migration : remboursements — investissement_id nullable + type + bonus ──
// Recrée la table pour lever la contrainte NOT NULL sur investissement_id
// et ajouter les colonnes bonus_plateforme_id, bonus_investisseur_id, type.
{
const rembColsBonus = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembColsBonus.includes('bonus_plateforme_id')) {
const tempName = '__repair_remboursements';
db.exec('PRAGMA foreign_keys = OFF');
db.exec('DROP VIEW IF EXISTS v_interets_annuels');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(`CREATE TABLE "${tempName}" (
id INTEGER PRIMARY KEY,
investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE,
bonus_plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE SET NULL,
bonus_investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
type TEXT NOT NULL DEFAULT 'normal',
date_remb TEXT NOT NULL,
capital REAL NOT NULL DEFAULT 0,
interets_bruts REAL NOT NULL DEFAULT 0,
prelev_sociaux REAL NOT NULL DEFAULT 0,
prelev_forfaitaire REAL NOT NULL DEFAULT 0,
cashback REAL NOT NULL DEFAULT 0,
interets_nets REAL NOT NULL DEFAULT 0,
net_recu REAL NOT NULL DEFAULT 0,
statut TEXT NOT NULL DEFAULT 'paye'
CHECK(statut IN ('paye','retard','partiel','impaye')),
source TEXT NOT NULL DEFAULT 'manuel',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
// Copier toutes les colonnes existantes (investissement_id était NOT NULL)
const existingCols = rembColsBonus.map(c => `"${c}"`).join(', ');
db.exec(`INSERT INTO "${tempName}" (${existingCols}) SELECT ${existingCols} FROM remboursements`);
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='remboursements' AND sql IS NOT NULL"
).all();
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE remboursements');
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO remboursements`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)');
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)');
// Recréer la vue (exclut les bonus qui n'ont pas d'investissement_id)
db.exec(`CREATE VIEW IF NOT EXISTS v_interets_annuels AS
SELECT
i.investisseur_id,
substr(r.date_remb,1,4) AS annee,
SUM(r.interets_bruts) AS interets_bruts,
SUM(r.prelev_sociaux) AS prelev_sociaux,
SUM(r.prelev_forfaitaire) AS prelev_forfaitaire,
SUM(r.net_recu) AS net_recu
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE r.statut IN ('paye','partiel')
AND r.type = 'normal'
GROUP BY i.investisseur_id, substr(r.date_remb,1,4)`);
db.exec('PRAGMA foreign_keys = ON');
}
}
// ── Migration : logo_filename sur plateformes ─────────────────────────────
{
const platColsLogo = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsLogo.includes('logo_filename')) {
db.exec('ALTER TABLE plateformes ADD COLUMN logo_filename TEXT');
}
}
// ── Migration : icone_filename sur plateformes ───────────────────────────
{
const platColsIcone = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsIcone.includes('icone_filename')) {
db.exec('ALTER TABLE plateformes ADD COLUMN icone_filename TEXT');
}
}
// ── Migration : investisseur_id + date_ouverture sur plateformes ──────────
{
const platCols2 = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platCols2.includes('investisseur_id')) {
db.exec('ALTER TABLE plateformes ADD COLUMN investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL');
// Backfill : associer au compte is_principal de l'utilisateur propriétaire
db.exec(`
UPDATE plateformes SET investisseur_id = (
SELECT inv.id FROM investisseurs inv
WHERE inv.user_id = plateformes.user_id AND inv.is_principal = 1
LIMIT 1
)
WHERE investisseur_id IS NULL
`);
}
if (!platCols2.includes('date_ouverture')) {
db.exec('ALTER TABLE plateformes ADD COLUMN date_ouverture TEXT');
}
}
// ── Migration : valeurs par défaut sur plateformes ────────────────────────
{
const platColsDef = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsDef.includes('type_pret_defaut')) {
db.exec('ALTER TABLE plateformes ADD COLUMN type_pret_defaut TEXT');
}
if (!platColsDef.includes('duree_defaut')) {
db.exec('ALTER TABLE plateformes ADD COLUMN duree_defaut INTEGER');
}
if (!platColsDef.includes('taux_defaut')) {
db.exec('ALTER TABLE plateformes ADD COLUMN taux_defaut REAL');
}
if (!platColsDef.includes('freq_interets_defaut')) {
db.exec("ALTER TABLE plateformes ADD COLUMN freq_interets_defaut TEXT");
}
}
// ── Migration : table comptes ─────────────────────────────────────────────
{
const comptesExists = db.prepare(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='comptes'"
).get().n > 0;
if (!comptesExists) {
db.exec(`
CREATE TABLE comptes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
nom TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'compte_courant',
banque TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_comptes_user ON comptes(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_comptes_inv ON comptes(investisseur_id)');
}
}
// ── Migration : exoneration_fiscale sur comptes ─────────────────────────────
{
const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name);
if (!cols.includes('exoneration_fiscale')) {
db.exec("ALTER TABLE comptes ADD COLUMN exoneration_fiscale TEXT NOT NULL DEFAULT 'aucune'");
}
}
// ── Migration : UNIQUE(user_id, nom) → UNIQUE(user_id, nom, investisseur_id) ─
// Permet d'avoir la même plateforme (même nom) détenue par deux investisseurs
// différents. SQLite ne supporte pas DROP CONSTRAINT → recréation de la table.
{
const platSql = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='plateformes'"
).get()?.sql ?? '';
// Détecte l'ancienne contrainte UNIQUE(user_id, nom) sans investisseur_id
const needsMigration = /UNIQUE\s*\(\s*user_id\s*,\s*nom\s*\)/.test(platSql)
&& !/UNIQUE\s*\(\s*user_id\s*,\s*nom\s*,\s*investisseur_id\s*\)/.test(platSql);
if (needsMigration) {
const tempName = '__repair_plateformes';
const platCols = db.prepare('PRAGMA table_info(plateformes)').all();
const colNames = platCols.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='plateformes' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(`CREATE TABLE "${tempName}" (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
url TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
domiciliation TEXT NOT NULL DEFAULT 'france',
fiscalite TEXT NOT NULL DEFAULT 'flat_tax',
taux_fiscalite_locale REAL,
methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille',
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
date_ouverture TEXT,
logo_filename TEXT,
UNIQUE(user_id, nom, investisseur_id)
)`);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM plateformes`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE plateformes');
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO plateformes`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id)');
}
}
// ── Migration : echeance_fin_de_mois sur investissements ─────────────────
// Indicateur "dernier jour du mois" pour les prêts in fine / différés
// dont la date de première échéance tombe après le 27 du mois.
{
const invColsEfm = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invColsEfm.includes('echeance_fin_de_mois')) {
db.exec('ALTER TABLE investissements ADD COLUMN echeance_fin_de_mois INTEGER NOT NULL DEFAULT 0');
}
}
// ── Migration : table reinvestissements ──────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS reinvestissements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
montant REAL NOT NULL,
date_reinvestissement TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_reinv_inv ON reinvestissements(investissement_id, date_reinvestissement)');
// ── Migration : table corrections_solde ──────────────────────────────────
// Corrections manuelles de solde porte-monnaie pour plateformes flat_tax.
// Permet de réconcilier les micro-écarts de calcul de taxe (ex. 0,01€)
// en déclarant le solde constaté après une opération dépôt/retrait.
db.exec(`
CREATE TABLE IF NOT EXISTS corrections_solde (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
date TEXT NOT NULL,
montant REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_investisseur ON corrections_solde(investisseur_id, date)');
db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_plateforme ON corrections_solde(plateforme_id, date)');
// ── Migration : fiscalité locale sur remboursements ──────────────────────
// Stocke le montant brut avant retenue et le montant de la taxe locale
// (pour les plateformes avec fiscalite = 'avec_fiscalite_locale').
{
const rembColsLocal = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembColsLocal.includes('interets_bruts_avant_local')) {
db.exec('ALTER TABLE remboursements ADD COLUMN interets_bruts_avant_local REAL NOT NULL DEFAULT 0');
}
if (!rembColsLocal.includes('taxe_locale')) {
db.exec('ALTER TABLE remboursements ADD COLUMN taxe_locale REAL NOT NULL DEFAULT 0');
}
}
// ── Migration : réinvestissement automatique ─────────────────────────────
// auto_reinvest sur investissements : active le réinvestissement automatique des intérêts.
// source sur reinvestissements : 'manuel' (défaut) ou 'auto'.
{
const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
const reinvCols = db.prepare('PRAGMA table_info(reinvestissements)').all().map(c => c.name);
if (!invCols.includes('auto_reinvest')) {
db.exec('ALTER TABLE investissements ADD COLUMN auto_reinvest INTEGER NOT NULL DEFAULT 0');
}
if (!reinvCols.includes('source')) {
db.exec("ALTER TABLE reinvestissements ADD COLUMN source TEXT NOT NULL DEFAULT 'manuel'");
}
}
// ── Migration : fiscalite_override sur investissements ──────────────────────
// Permet d'exonérer un investissement de la flat tax, indépendamment du paramétrage
// de la plateforme (ex : investissement logé en PEA-PME).
// Valeurs : null (suit la plateforme) | 'exonere' (traité comme sans_fiscalite_locale).
{
const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invCols.includes('fiscalite_override')) {
db.exec('ALTER TABLE investissements ADD COLUMN fiscalite_override TEXT');
}
}
// ── Migration : nom_compte_courant sur investissements ──────────────────────
// Nom du compte bancaire de l'investisseur quand methode_remboursement = 'compte_courant'.
{
const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invCols.includes('nom_compte_courant')) {
db.exec('ALTER TABLE investissements ADD COLUMN nom_compte_courant TEXT');
}
}
// ── Migration : methode_remboursement sur investissements ────────────────────
// Permet à l'investisseur de préciser sa préférence de remboursement quand la
// plateforme est configurée 'choix_investisseur'.
// Valeurs : 'portefeuille' | 'compte_courant' | null (non renseigné).
{
const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invCols.includes('methode_remboursement')) {
db.exec('ALTER TABLE investissements ADD COLUMN methode_remboursement TEXT');
}
}
// ── Migration : pays_exposition sur investissements ──────────────────────────
// Permet d'enregistrer le pays d'exposition du prêt (code ISO 3166-1 alpha-2).
// Défaut : 'FR' (France).
{
const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!invCols.includes('pays_exposition')) {
db.exec("ALTER TABLE investissements ADD COLUMN pays_exposition TEXT NOT NULL DEFAULT 'FR'");
}
}
// ── Table user_preferences ───────────────────────────────────────────────────
// Stockage générique des préférences UI par utilisateur.
// Clé/valeur (TEXT) avec upsert. Extensible à toutes les prefs futures.
db.exec(`
CREATE TABLE IF NOT EXISTS user_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key)
)
`);
// ── Tables app_icons + app_icons_history ─────────────────────────────────────
// Bibliothèque d'icônes de l'application, gérée depuis /admin.
// `app_icons` : association nom (slug) ↔ fichier actif
// `app_icons_history` : historique des fichiers remplacés
db.exec(`
CREATE TABLE IF NOT EXISTS app_icons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS app_icons_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
icon_id INTEGER NOT NULL REFERENCES app_icons(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
replaced_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Seed initial depuis design/Icones/svg ────────────────────────────────────
{
const count = db.prepare('SELECT COUNT(*) AS n FROM app_icons').get().n;
if (count === 0) {
const seeds = [
{ name: 'balance', filename: 'icon_balance_seed.svg', description: 'Balance / équilibre' },
{ name: 'capital', filename: 'icon_capital_1779539824176.svg', description: 'Capital investi' },
{ name: 'cashback', filename: 'icon_cashback_seed.svg', description: 'Cashback / bonus' },
{ name: 'compte-courant', filename: 'icon_compte-courant_1780313604075.svg', description: 'Compte courant' },
{ name: 'dashboard', filename: 'icon_dashboard_1780232184451.svg', description: 'Dashboard financier' },
{ name: 'depot', filename: 'icon_depot_1779539637657.svg', description: 'Dépôt de fonds' },
{ name: 'depots-retraits', filename: 'icon_depots-retraits_1780231579297.svg', description: 'Dépôts / Retraits' },
{ name: 'interets', filename: 'icon_interets_seed.svg', description: 'Intérêts perçus' },
{ name: 'investissement', filename: 'icon_investissement_seed.svg', description: 'Investissement' },
{ name: 'plateforme', filename: 'icon_plateforme_seed.svg', description: 'Plateforme de prêt' },
{ name: 'plateforme-fr', filename: 'icon_plateforme-fr_1780332257672.png', description: 'Plateforme fraçaise' },
{ name: 'plateforme-ww', filename: 'icon_plateforme-ww_1780332295418.png', description: 'Plateforme étrangère' },
{ name: 'porte-monnaie', filename: 'icon_porte-monnaie_1779539652739.svg', description: 'Porte-monnaie' },
{ name: 'remboursement', filename: 'icon_remboursement_seed.svg', description: 'Remboursement' },
{ name: 'retrait', filename: 'icon_retrait_seed.svg', description: 'Retrait de fonds' },
{ name: 'tax', filename: 'icon_tax_1780230337883.svg', description: 'Fiscalité / Tax' },
{ name: 'logo-app', filename: 'app-logo.svg', description: 'Logo principal de la plateforme' },
];
const ins = db.prepare(
'INSERT OR IGNORE INTO app_icons (name, filename, description) VALUES (?,?,?)'
);
for (const s of seeds) ins.run(s.name, s.filename, s.description);
} else {
// Garantit que logo-app existe même sur une DB déjà peuplée
db.prepare(`
INSERT OR IGNORE INTO app_icons (name, filename, description)
VALUES ('logo-app', 'app-logo.svg', 'Logo principal de la plateforme')
`).run();
}
// Génère le fichier app-logo.svg dans le dossier icons s'il est absent
{
const dataDir = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: path.resolve(__dirname, '../../data');
const logoPath = path.join(dataDir, 'icons', 'app-logo.svg');
if (!fs.existsSync(logoPath)) {
fs.mkdirSync(path.dirname(logoPath), { recursive: true });
fs.writeFileSync(logoPath, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#1e3a6e"/>
<rect x="14" y="62" width="20" height="24" rx="3" fill="#4a7fc1" opacity="0.85"/>
<rect x="40" y="46" width="20" height="40" rx="3" fill="#6a9fd8" opacity="0.9"/>
<rect x="66" y="30" width="20" height="56" rx="3" fill="#8ab8e8"/>
<polygon points="76,8 90,28 82,28 82,36 70,36 70,28 62,28" fill="#22c55e"/>
</svg>`, 'utf8');
console.log('[DB] app-logo.svg généré dans', path.dirname(logoPath));
}
}
}
// ── Migration : compte_id sur investissements ────────────────────────────────
// Lie un investissement au compte bancaire du détenteur (FK → comptes.id).
{
const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!cols.includes('compte_id')) {
db.exec('ALTER TABLE investissements ADD COLUMN compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL');
}
}
// ── Migration : compte_id sur remboursements ─────────────────────────────────
// Lie un remboursement au compte bancaire récepteur (FK → comptes.id).
{
const cols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!cols.includes('compte_id')) {
db.exec('ALTER TABLE remboursements ADD COLUMN compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL');
}
}
// ── Table taux_credit_impot — Référentiel 2047 crédit d'impôt par pays ───────
// Stocke les taux de crédit d'impôt applicables sur dividendes et intérêts
// selon les conventions fiscales, pour la déclaration 2047.
// Alimenté depuis les pages 5-6 de la notice DGFiP 2047 (édition 2024).
{
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name);
if (!tables.includes('taux_credit_impot')) {
db.exec(`
CREATE TABLE taux_credit_impot (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom_pays TEXT NOT NULL,
code_pays TEXT,
div_taux REAL,
div_taux_alt REAL,
div_taux_alt_label TEXT,
div_exclusif_residence INTEGER NOT NULL DEFAULT 0,
int_taux REAL,
int_taux_alt REAL,
int_taux_alt_label TEXT,
int_exclusif_residence INTEGER NOT NULL DEFAULT 0,
notice TEXT,
statut_convention TEXT NOT NULL DEFAULT 'active',
date_suspension TEXT,
ref_boi TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Seed depuis notice DGFiP 2047 (pages 5-6)
// Colonnes : nom_pays, code_pays, div_taux, div_taux_alt, div_taux_alt_label,
// div_excl_res, int_taux, int_taux_alt, int_taux_alt_label, int_excl_res,
// statut_convention, date_suspension, ref_boi, notice
const seed = [
["Afrique du Sud","ZA",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Albanie","AL",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Algérie","DZ",17.6,null,null,0,13.6,null,null,0,"active",null,null,null],
["Allemagne","DE",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Andorre","AD",17.6,null,null,0,5.3,null,null,0,"active",null,null,null],
["Arabie Saoudite","SA",null,null,null,1,null,null,null,1,"active",null,null,null],
["Argentine","AR",17.6,null,null,0,25.0,null,null,0,"active",null,null,null],
["Arménie","AM",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Australie","AU",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Autriche","AT",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Azerbaïdjan","AZ",11.1,null,null,0,11.1,null,null,0,"active",null,null,null],
["Bahreïn","BH",null,null,null,1,null,null,null,1,"active",null,null,null],
["Bangladesh","BD",25.0,null,null,0,25.0,null,null,0,"active",null,null,"Dividendes et intérêts : le crédit d'impôt est égal à l'impôt prélevé au Bangladesh dans les limites prévues par la convention, augmenté de 10 % du montant brut du revenu, sans que le total puisse excéder 20 % du montant brut de ce revenu. En l'absence de retenue à la source au Bangladesh, le taux du crédit d'impôt est de 10 %."],
["Belgique","BE",17.6,null,null,0,17.6,null,null,0,"active",null,null,null],
["Bénin","BJ",25.0,null,null,0,null,null,null,0,"active",null,null,"Intérêts : le crédit d'impôt est égal à l'impôt prélevé au Bénin. Dividendes : la déduction correspondant à l'impôt effectivement payé au Bénin est égale à 25 % du montant brut des revenus."],
["Biélorussie","BY",17.6,null,null,0,11.1,null,null,0,"suspendue","2024-06-01",null,"La convention fiscale applicable entre la France et la Biélorussie est suspendue à compter du 1er juin 2024. Convention ex-URSS."],
["Bolivie","BO",17.6,null,null,0,17.6,null,null,0,"active",null,null,null],
["Bosnie-Herzégovine","BA",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."],
["Botswana","BW",13.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Brésil","BR",20.0,null,null,0,20.0,null,null,0,"active",null,null,"Dividendes et intérêts : crédit d'impôt forfaitaire égal à 20 % du montant brut de ceux-ci lorsque les revenus ont été effectivement imposés au Brésil."],
["Bulgarie","BG",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Burkina Faso","BF",null,null,null,0,null,null,null,0,"caduque","2023-11-08","BOI-INT-CVB-BFA","La convention fiscale conclue entre la France et le Burkina Faso a cessé de produire ses effets à compter du 8 novembre 2023."],
["Cameroun","CM",17.6,null,null,0,17.6,null,null,0,"active",null,null,null],
["Canada","CA",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Québec compris."],
["Centrafricaine (Rép.)","CF",33.3,null,null,0,13.6,null,null,0,"active",null,null,null],
["Chili","CL",null,null,null,0,10.0,null,null,0,"active",null,null,"Dividendes : crédit d'impôt égal à la plus petite des sommes suivantes : montant de l'impôt additionnel payé au Chili après déduction de l'impôt de première catégorie, ou 15 % du montant brut des dividendes. Intérêts : crédit d'impôt de 10 % en application de la clause de la nation la plus favorisée."],
["Chine","CN",11.1,null,null,0,11.1,null,null,0,"active",null,null,null],
["Chypre","CY",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Colombie","CO",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Congo","CG",25.0,null,null,0,null,null,null,1,"active",null,null,null],
["Corée du Sud","KR",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Côte d'Ivoire","CI",17.6,22.0,"Société exonérée ou à taux réduit IS",0,17.6,null,null,0,"active",null,null,"Dividendes : crédit d'impôt plafonné à 18 % du montant brut lorsqu'ils sont payés par une société exonérée de l'IS ou acquittant cet impôt à taux réduit, et à 15 % dans les autres cas."],
["Croatie","HR",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Danemark","DK",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Égypte","EG",null,null,null,1,17.6,null,null,0,"active",null,null,null],
["Émirats arabes unis","AE",null,null,null,1,null,null,null,1,"active",null,null,null],
["Équateur","EC",17.6,null,null,0,17.6,11.1,"Conditions particulières conv.",0,"active",null,null,null],
["Espagne","ES",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Estonie","EE",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["États-Unis","US",17.6,null,null,0,17.6,null,null,0,"active",null,null,"Dividendes : crédit d'impôt égal à l'impôt américain, dans la limite de 15 % du montant brut. Certains dividendes de résidents de France possédant la citoyenneté américaine ouvrent droit à un crédit d'impôt égal au montant de l'impôt français (art. 24 §1-b-i de la convention du 31 août 1994). Intérêts : crédit d'impôt égal à l'impôt américain, dans la limite de 15 % du montant brut. Les intérêts exonérés aux États-Unis n'ouvrent droit à aucun crédit d'impôt en France."],
["Éthiopie","ET",5.3,null,null,0,5.3,null,null,0,"active",null,null,null],
["Finlande","FI",null,null,null,1,11.1,null,null,0,"active",null,null,null],
["Gabon","GA",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Géorgie","GE",11.1,null,null,0,null,null,null,1,"active",null,null,null],
["Ghana","GH",17.6,null,null,0,14.3,null,null,0,"active",null,null,null],
["Grèce","GR",17.6,null,null,0,5.3,null,null,0,"active",null,null,null],
["Guinée","GN",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Hong-Kong","HK",11.1,null,null,0,11.1,null,null,0,"active",null,null,null],
["Hongrie","HU",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Inde","IN",11.1,null,null,0,10.0,null,null,0,"active",null,null,"Intérêts : crédit d'impôt de 10 % en application de la clause de la nation la plus favorisée. Les intérêts de source indienne n'ayant pas supporté l'impôt en Inde ouvrent droit à un crédit forfaitaire correspondant à l'impôt qui aurait été perçu, plafonné à 10 %."],
["Indonésie","ID",17.6,null,null,0,17.6,null,null,0,"active",null,null,"Dividendes et intérêts : crédit d'impôt égal à 10 % du montant brut lorsque l'impôt indonésien n'est pas perçu ou lorsqu'il est perçu à un taux inférieur à 10 % du fait de mesures spéciales incitatives."],
["Iran","IR",25.0,null,null,0,17.6,null,null,0,"active",null,null,null],
["Irlande","IE",null,null,null,1,null,null,null,1,"active",null,null,null],
["Islande","IS",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Israël","IL",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Italie","IT",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Jamaïque","JM",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Japon","JP",11.1,null,null,0,11.1,null,null,0,"active",null,null,null],
["Jordanie","JO",17.6,null,null,0,17.6,null,null,0,"active",null,null,null],
["Kazakhstan","KZ",11.1,null,null,0,11.1,null,null,0,"active",null,null,null],
["Kenya","KE",11.1,null,null,0,13.6,null,null,0,"active",null,null,null],
["Kirghizistan","KG",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Convention fiscale conclue entre la France et l'ex-URSS."],
["Kosovo","XK",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."],
["Koweït","KW",null,null,null,1,null,null,null,1,"active",null,null,null],
["Lettonie","LV",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Liban","LB",null,null,null,1,null,null,null,1,"active",null,null,null],
["Libye","LY",11.1,null,null,0,null,null,null,1,"active",null,null,null],
["Lituanie","LT",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Luxembourg","LU",17.6,null,null,0,null,null,null,1,"active",null,null,"Dividendes : le crédit d'impôt ne peut excéder 25 % du montant brut des dividendes."],
["Macédoine","MK",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Madagascar","MG",33.3,null,null,0,17.6,null,null,0,"active",null,null,null],
["Malaisie","MY",null,null,null,1,17.6,null,null,0,"active",null,null,"Intérêts : le crédit d'impôt est égal à l'impôt prélevé en Malaisie."],
["Mali","ML",null,null,null,0,null,null,null,0,"caduque","2024-03-05","BOI-INT-CVB-MLI","La convention fiscale conclue entre la France et le Mali a cessé de produire ses effets à compter du 5 mars 2024."],
["Malte","MT",17.6,null,null,0,5.3,null,null,0,"active",null,null,null],
["Maroc","MA",25.0,null,null,0,17.6,11.1,"Emprunts organismes de développement",0,"active",null,null,"Dividendes ayant supporté l'impôt au Maroc : crédit d'impôt égal à 25 % du montant brut. Intérêts : crédit forfaitaire de 10 % lorsqu'ils proviennent d'emprunts émis par certains organismes spécialisés pour le développement économique du Maroc (plafonné à l'impôt français). Dans les autres cas, CI égal à l'impôt effectivement supporté."],
["Maurice","MU",33.3,null,null,0,null,null,null,0,"active",null,null,"Dividendes : le crédit d'impôt ne peut excéder 25 % du montant brut des dividendes."],
["Mauritanie","MR",33.3,null,null,0,16.0,12.0,"Obligations négociables",0,"active",null,null,"Dividendes : crédit d'impôt égal à 25 % du montant brut lorsque effectivement imposés en Mauritanie. Intérêts : crédit d'impôt de 16 % du montant brut (prêts, dépôts, bons de caisse non négociables) ; 12 % pour les obligations négociables."],
["Mexique","MX",17.6,null,null,0,11.1,5.3,"Conditions particulières conv.",0,"active",null,null,null],
["Moldavie","MD",11.1,null,null,0,5.3,null,null,0,"active",null,null,null],
["Mongolie","MN",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Dividendes et intérêts : l'art. 23 §I d) iii prévoit une modalité particulière de calcul du crédit d'impôt lorsque le revenu a bénéficié d'une réduction ou d'une suppression de l'impôt à la source qui n'est plus applicable."],
["Monténégro","ME",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."],
["Namibie","NA",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Niger","NE",null,null,null,0,null,null,null,0,"suspendue","2024-06-05","BOI-INT-CVB-NER","La convention fiscale conclue entre la France et le Niger est suspendue à compter du 5 juin 2024. Intérêts : CI égal à l'impôt payé au Niger ; intérêts de prêts/dépôts non négociables ouvrent droit à CI de 16 % du montant brut."],
["Nigéria","NG",17.6,null,null,0,14.3,null,null,0,"active",null,null,"Dividendes et intérêts : crédit d'impôt de 15 % (div.) ou 12,5 % (int.) lorsqu'ils sont exonérés ou soumis à taux réduit en vertu de la législation nigériane sur le développement économique."],
["Norvège","NO",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Nouvelle-Calédonie","NC",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Nouvelle-Zélande","NZ",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Oman","OM",null,null,null,1,null,null,null,1,"active",null,null,null],
["Ouzbékistan","UZ",8.7,null,null,0,5.3,null,null,0,"active",null,null,null],
["Pakistan","PK",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Panama","PA",17.6,null,null,0,5.3,null,null,0,"active",null,null,null],
["Pays-Bas","NL",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Philippines","PH",17.6,null,null,0,17.6,null,null,0,"active",null,null,null],
["Pologne","PL",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Polynésie française","PF",null,null,null,1,null,null,null,1,"active",null,null,"Les dividendes et intérêts de source polynésienne n'ouvrent pas droit à crédit d'impôt."],
["Portugal","PT",17.6,null,null,0,10.0,12.0,"Autres emprunts (hors obligations négociables)",0,"active",null,null,"Intérêts ayant donné lieu à imposition au Portugal : 10 % du montant brut pour les obligations et titres d'emprunts négociables ; 12 % du montant brut pour tous autres emprunts."],
["Qatar","QA",null,null,null,1,null,null,null,1,"active",null,null,null],
["République tchèque","CZ",11.1,null,null,0,null,null,null,1,"active",null,null,null],
["Roumanie","RO",11.1,null,null,0,11.1,null,null,0,"active",null,null,null],
["Royaume-Uni","GB",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Russie","RU",null,null,null,0,null,null,null,0,"suspendue","2023-08-08","BOI-INT-CVB-RUS","La convention fiscale conclue entre la France et la Russie est partiellement suspendue à compter du 8 août 2023."],
["Saint-Barthélemy","BL",null,null,null,0,null,null,null,0,"active",null,null,"En l'absence de convention fiscale, l'art. L.O. 6214-4 du CGCT prévoit l'octroi d'un crédit d'impôt égal à l'impôt payé à Saint-Barthélemy. Cette collectivité n'imposant que les plus-values immobilières, aucun crédit d'impôt n'est accordé pour les autres catégories de revenu."],
["Saint-Martin","MF",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Saint-Pierre-et-Miquelon","PM",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Sénégal","SN",17.6,null,null,0,17.6,null,null,0,"active",null,null,null],
["Serbie","RS",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."],
["Singapour","SG",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Slovaquie","SK",11.1,null,null,0,null,null,null,1,"active",null,null,null],
["Slovénie","SI",17.6,null,null,0,5.3,null,null,0,"active",null,null,null],
["Sri Lanka","LK",25.0,null,null,0,15.0,null,null,0,"active",null,null,"Dividendes : crédit d'impôt égal à 25 % du montant brut. Intérêts : crédit d'impôt égal à 15 % du montant brut des intérêts de source sri-lankaise ayant supporté un impôt inférieur."],
["Suède","SE",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Suisse","CH",17.6,null,null,0,null,null,null,1,"active",null,null,null],
["Syrie","SY",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Taïwan","TW",11.1,null,null,0,11.1,null,null,0,"active",null,null,null],
["Thaïlande","TH",25.0,null,null,0,null,null,null,1,"active",null,null,"Dividendes : le crédit d'impôt ne peut excéder 25 % du montant brut des dividendes."],
["Togo","TG",33.3,null,null,0,13.6,null,null,0,"active",null,null,null],
["Trinité-et-Tobago","TT",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Les intérêts et dividendes visés à l'art. 24-2-c ouvrent droit à un CI correspondant à l'impôt qui aurait été perçu par Trinité-et-Tobago en l'absence des dispositions particulières, plafonné au montant de la retenue à la source prévue aux articles 10 et 11 de la convention."],
["Tunisie","TN",null,null,null,0,13.6,null,null,0,"active",null,null,"Dividendes : CI calculé par la formule (100 – (25 + t)) / 2, où t est le taux de la retenue à la source tunisienne. Aucun CI n'est accordé si la Tunisie n'impose pas ces revenus."],
["Turkménistan","TM",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Convention fiscale conclue entre la France et l'ex-URSS."],
["Turquie","TR",25.0,null,null,0,17.6,null,null,0,"active",null,null,"Dividendes et intérêts : lorsque ces revenus bénéficient d'une exonération ou d'une réduction d'impôt en vertu de mesures sur le développement économique, le CI est égal à l'impôt qui aurait dû être payé en l'absence de ces mesures (max 20 % du montant brut des div., max 15 % pour les int.)."],
["Ukraine","UA",17.6,null,null,0,11.1,null,null,0,"active",null,null,null],
["Venezuela","VE",5.3,null,null,0,5.3,null,null,0,"active",null,null,null],
["Viêt Nam","VN",11.1,null,null,0,null,null,null,1,"active",null,null,null],
["Zimbabwe","ZW",25.0,null,null,0,11.1,null,null,0,"active",null,null,null]
];
const ins = db.prepare(
'INSERT INTO taux_credit_impot' +
' (nom_pays, code_pays,' +
' div_taux, div_taux_alt, div_taux_alt_label, div_exclusif_residence,' +
' int_taux, int_taux_alt, int_taux_alt_label, int_exclusif_residence,' +
' statut_convention, date_suspension, ref_boi, notice)' +
' VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
);
const txn = db.transaction((rows) => { for (const r of rows) ins.run(...r); });
txn(seed);
}
}
// ── Migration : référentiel commun de plateformes (admin) ────────────────────
// plateformes_referentiel : données partagées gérées par l'administrateur.
// referentiel_categories : junction référentiel ↔ catégories.
// referentiel_notation : critères de notation attachés au référentiel.
// plateformes.referentiel_id : lien vers l'entrée référentiel (nullable).
// plateformes.overridden_fields : JSON array des champs modifiés par l'user.
{
db.exec(`
CREATE TABLE IF NOT EXISTS plateformes_referentiel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL UNIQUE,
url TEXT,
domiciliation TEXT NOT NULL DEFAULT 'france',
fiscalite TEXT NOT NULL DEFAULT 'flat_tax',
taux_fiscalite_locale REAL,
type_produit_fiscal TEXT NOT NULL DEFAULT '2TT',
logo_filename TEXT,
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_ref_nom ON plateformes_referentiel(nom)');
// Si la table existe avec l'ancien schéma FK (categorie_id), on la recrée
{
const refCatSql = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='referentiel_categories'"
).get()?.sql ?? '';
if (refCatSql && refCatSql.includes('categorie_id')) {
db.exec('DROP TABLE referentiel_categories');
}
}
db.exec(`
CREATE TABLE IF NOT EXISTS referentiel_categories (
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
categorie_nom TEXT NOT NULL,
PRIMARY KEY (referentiel_id, categorie_nom)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS referentiel_notation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'etoiles',
valeurs TEXT,
min_val REAL,
max_val REAL,
description TEXT,
ordre INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ref_notation ON referentiel_notation(referentiel_id, ordre)');
// Colonnes d'héritage sur plateformes
const platColsRef = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsRef.includes('referentiel_id')) {
db.exec('ALTER TABLE plateformes ADD COLUMN referentiel_id INTEGER REFERENCES plateformes_referentiel(id) ON DELETE SET NULL');
}
if (!platColsRef.includes('overridden_fields')) {
db.exec("ALTER TABLE plateformes ADD COLUMN overridden_fields TEXT NOT NULL DEFAULT '[]'");
}
}
// ── Migration : icone_filename sur plateformes_referentiel ────────────────
{
const refCols = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name);
if (!refCols.includes('icone_filename')) {
db.exec('ALTER TABLE plateformes_referentiel ADD COLUMN icone_filename TEXT');
}
}
// ── Migration : champs remboursement sur plateformes_referentiel ─────────────
{
const refCols = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name);
if (!refCols.includes('methode_remboursement'))
db.exec("ALTER TABLE plateformes_referentiel ADD COLUMN methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'");
if (!refCols.includes('type_pret_defaut'))
db.exec('ALTER TABLE plateformes_referentiel ADD COLUMN type_pret_defaut TEXT');
if (!refCols.includes('freq_interets_defaut'))
db.exec('ALTER TABLE plateformes_referentiel ADD COLUMN freq_interets_defaut TEXT');
// Remontée : récupère les valeurs depuis les plateformes liées (première valeur non nulle)
db.exec(`
UPDATE plateformes_referentiel
SET
methode_remboursement = COALESCE(
methode_remboursement,
(SELECT p.methode_remboursement FROM plateformes p
WHERE p.referentiel_id = plateformes_referentiel.id
AND p.methode_remboursement IS NOT NULL LIMIT 1)
),
type_pret_defaut = COALESCE(
type_pret_defaut,
(SELECT p.type_pret_defaut FROM plateformes p
WHERE p.referentiel_id = plateformes_referentiel.id
AND p.type_pret_defaut IS NOT NULL LIMIT 1)
),
freq_interets_defaut = COALESCE(
freq_interets_defaut,
(SELECT p.freq_interets_defaut FROM plateformes p
WHERE p.referentiel_id = plateformes_referentiel.id
AND p.freq_interets_defaut IS NOT NULL LIMIT 1)
)
`);
}
// ── Migration : champs profil sur plateformes_referentiel ────────────────
{
const refCols = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name);
const addCol = (col, def) => {
if (!refCols.includes(col)) db.exec(`ALTER TABLE plateformes_referentiel ADD COLUMN ${col} ${def}`);
};
// Identité
addCol('annee_creation', 'INTEGER');
addCol('type_investissement', 'TEXT'); // 'p2p' | 'dette' | 'equity' | 'mixte'
addCol('secteur', 'TEXT'); // 'immobilier' | 'pme' | 'startups' | 'energie' | ...
addCol('investisseurs_types', 'TEXT'); // 'particulier' | 'entreprise' | 'les_deux'
// Régulation
addCol('regulateur', 'TEXT');
addCol('numero_licence', 'TEXT');
addCol('is_regule', 'INTEGER NOT NULL DEFAULT 0');
// Localisation
addCol('pays_inscription', 'TEXT');
addCol('pays_siege', 'TEXT');
addCol('pays_operation', 'TEXT'); // JSON array ex: '["France","Allemagne"]'
// Stats / conditions
addCol('investissement_minimum', 'REAL');
addCol('rendement_annonce', 'REAL');
addCol('nb_investisseurs', 'INTEGER');
addCol('volume_total_finance', 'REAL');
addCol('duree_moyenne_pret', 'REAL'); // en mois
// Features booléennes
addCol('garantie_rachat', 'INTEGER NOT NULL DEFAULT 0');
addCol('statistiques_publiques', 'INTEGER NOT NULL DEFAULT 0');
addCol('bonus_inscription', 'INTEGER NOT NULL DEFAULT 0');
addCol('marche_secondaire', 'INTEGER NOT NULL DEFAULT 0');
addCol('investissement_auto', 'INTEGER NOT NULL DEFAULT 0');
// Liens complémentaires
addCol('url_trustpilot', 'TEXT');
addCol('url_linkedin', 'TEXT');
}
// ── Migration : categories_inv + secteurs_inv (listes admin globales) ──────
{
// Labels lisibles pour la migration des données existantes
const TYPES_INV_LABELS = {
p2p: 'Prêt P2P',
dette: 'Dette',
equity: 'Capitaux propres',
tokenise: 'Tokenisé',
achat_louer: 'Achat à louer',
mini_obligations: 'Mini-obligations',
mixte: 'Mixte',
};
const SECTEURS_LABELS = {
immobilier: 'Immobilier',
pme: 'PME',
startups: 'Startups',
litige: 'Litige',
energie: 'Énergie verte',
sante_science: 'Santé & Science',
logistique: 'Logistique',
personnel: 'Prêts personnels',
art: 'Art',
autre: 'Autre',
};
// 1. Créer les tables globales
db.exec(`
CREATE TABLE IF NOT EXISTS categories_inv (
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_inv (
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
categorie_id INTEGER NOT NULL REFERENCES categories_inv(id) ON DELETE CASCADE,
PRIMARY KEY (referentiel_id, categorie_id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS secteurs_inv (
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_secteurs_inv (
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
secteur_id INTEGER NOT NULL REFERENCES secteurs_inv(id) ON DELETE CASCADE,
PRIMARY KEY (referentiel_id, secteur_id)
)
`);
// 2. Seeder categories_inv si vide
const catCount = db.prepare('SELECT COUNT(*) AS n FROM categories_inv').get().n;
if (catCount === 0) {
const insC = db.prepare('INSERT OR IGNORE INTO categories_inv (nom) VALUES (?)');
db.transaction(() => {
// Valeurs par défaut depuis TYPES_INV
for (const nom of Object.values(TYPES_INV_LABELS)) insC.run(nom);
// Valeurs existantes depuis referentiel_categories (chaînes libres)
const existing = db.prepare('SELECT DISTINCT categorie_nom FROM referentiel_categories').all();
for (const { categorie_nom } of existing) insC.run(categorie_nom);
// Valeurs existantes depuis type_investissement (slugs → labels)
const typesExist = db.prepare('SELECT DISTINCT type_investissement FROM plateformes_referentiel WHERE type_investissement IS NOT NULL').all();
for (const { type_investissement: t } of typesExist) {
const label = TYPES_INV_LABELS[t] || t;
insC.run(label);
}
})();
}
// 3. Seeder secteurs_inv si vide
const sectCount = db.prepare('SELECT COUNT(*) AS n FROM secteurs_inv').get().n;
if (sectCount === 0) {
const insS = db.prepare('INSERT OR IGNORE INTO secteurs_inv (nom) VALUES (?)');
db.transaction(() => {
for (const nom of Object.values(SECTEURS_LABELS)) insS.run(nom);
// Valeurs existantes depuis le champ secteur
const sectsExist = db.prepare('SELECT DISTINCT secteur FROM plateformes_referentiel WHERE secteur IS NOT NULL').all();
for (const { secteur: s } of sectsExist) {
const label = SECTEURS_LABELS[s] || s;
insS.run(label);
}
})();
}
// 4. Migrer les données existantes → referentiel_categories_inv
const alreadyMigrated = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_inv').get().n;
if (alreadyMigrated === 0) {
const getCatId = db.prepare('SELECT id FROM categories_inv WHERE nom = ?');
const insLink = db.prepare('INSERT OR IGNORE INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?,?)');
const refs = db.prepare('SELECT id, type_investissement FROM plateformes_referentiel').all();
const oldCats = db.prepare('SELECT referentiel_id, categorie_nom FROM referentiel_categories').all();
db.transaction(() => {
for (const ref of refs) {
// Depuis type_investissement
if (ref.type_investissement) {
const label = TYPES_INV_LABELS[ref.type_investissement] || ref.type_investissement;
const cat = getCatId.get(label);
if (cat) insLink.run(ref.id, cat.id);
}
}
// Depuis referentiel_categories (chaînes libres)
for (const { referentiel_id, categorie_nom } of oldCats) {
const cat = getCatId.get(categorie_nom);
if (cat) insLink.run(referentiel_id, cat.id);
}
})();
}
// 5. Migrer les données existantes → referentiel_secteurs_inv
const alreadySectMigrated = db.prepare('SELECT COUNT(*) AS n FROM referentiel_secteurs_inv').get().n;
if (alreadySectMigrated === 0) {
const getSectId = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ?');
const insSectLink = db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)');
const refs = db.prepare('SELECT id, secteur FROM plateformes_referentiel WHERE secteur IS NOT NULL').all();
db.transaction(() => {
for (const ref of refs) {
const label = SECTEURS_LABELS[ref.secteur] || ref.secteur;
const sect = getSectId.get(label);
if (sect) insSectLink.run(ref.id, sect.id);
}
})();
}
}
// ── Migration : domiciliation texte → code ISO 3166-1 alpha-2 ────────────────
{
const OLD_VALUES = ['france', 'zone_europeenne', 'hors_zone_europeenne'];
// plateformes (par user)
const hasOldPlat = db.prepare(
`SELECT 1 FROM plateformes WHERE domiciliation IN ('france','zone_europeenne','hors_zone_europeenne') LIMIT 1`
).get();
if (hasOldPlat) {
db.exec(`UPDATE plateformes SET domiciliation = 'FR' WHERE domiciliation = 'france'`);
// zone_europeenne / hors_zone_europeenne → 'EU' provisoire, à corriger via l'interface
db.exec(`UPDATE plateformes SET domiciliation = 'EU' WHERE domiciliation = 'zone_europeenne'`);
db.exec(`UPDATE plateformes SET domiciliation = 'EU' WHERE domiciliation = 'hors_zone_europeenne'`);
console.log('[DB] Migration domiciliation → ISO : plateformes mises à jour.');
}
// plateformes_referentiel (admin)
const hasOldRef = db.prepare(
`SELECT 1 FROM plateformes_referentiel WHERE domiciliation IN ('france','zone_europeenne','hors_zone_europeenne') LIMIT 1`
).get();
if (hasOldRef) {
db.exec(`UPDATE plateformes_referentiel SET domiciliation = 'FR' WHERE domiciliation = 'france'`);
db.exec(`UPDATE plateformes_referentiel SET domiciliation = 'EU' WHERE domiciliation = 'zone_europeenne'`);
db.exec(`UPDATE plateformes_referentiel SET domiciliation = 'EU' WHERE domiciliation = 'hors_zone_europeenne'`);
console.log('[DB] Migration domiciliation → ISO : référentiel mis à jour.');
}
}
// ── Migration : pays_siege → domiciliation (ISO) sur plateformes_referentiel ──
{
const NAME_TO_ISO = {
'Afghanistan': 'AF', 'Afrique du Sud': 'ZA', 'Albanie': 'AL', 'Algérie': 'DZ',
'Allemagne': 'DE', 'Andorre': 'AD', 'Angola': 'AO', 'Antigua-et-Barbuda': 'AG',
'Arabie saoudite': 'SA', 'Argentine': 'AR', 'Arménie': 'AM', 'Australie': 'AU',
'Autriche': 'AT', 'Azerbaïdjan': 'AZ', 'Bahamas': 'BS', 'Bahreïn': 'BH',
'Bangladesh': 'BD', 'Barbade': 'BB', 'Biélorussie': 'BY', 'Belgique': 'BE',
'Belize': 'BZ', 'Bénin': 'BJ', 'Bhoutan': 'BT', 'Bolivie': 'BO',
'Bosnie-Herzégovine': 'BA', 'Botswana': 'BW', 'Brésil': 'BR', 'Brunéi': 'BN',
'Bulgarie': 'BG', 'Burkina Faso': 'BF', 'Burundi': 'BI', 'Cap-Vert': 'CV',
'Cambodge': 'KH', 'Cameroun': 'CM', 'Canada': 'CA', 'République centrafricaine': 'CF',
'Chili': 'CL', 'Chine': 'CN', 'Chypre': 'CY', 'Colombie': 'CO',
'Comores': 'KM', 'Congo': 'CG', 'Congo (RDC)': 'CD', 'Corée du Nord': 'KP',
'Corée du Sud': 'KR', 'Costa Rica': 'CR', 'Croatie': 'HR', 'Cuba': 'CU',
'Danemark': 'DK', 'Djibouti': 'DJ', 'République dominicaine': 'DO', 'Dominique': 'DM',
'Égypte': 'EG', 'Salvador': 'SV', 'Émirats arabes unis': 'AE', 'Équateur': 'EC',
'Érythrée': 'ER', 'Espagne': 'ES', 'Estonie': 'EE', 'Eswatini': 'SZ',
'Éthiopie': 'ET', 'Fidji': 'FJ', 'Finlande': 'FI', 'France': 'FR',
'Gabon': 'GA', 'Gambie': 'GM', 'Géorgie': 'GE', 'Ghana': 'GH',
'Grèce': 'GR', 'Grenade': 'GD', 'Guatemala': 'GT', 'Guinée': 'GN',
'Guinée-Bissau': 'GW', 'Guinée équatoriale': 'GQ', 'Guyana': 'GY', 'Haïti': 'HT',
'Honduras': 'HN', 'Hongrie': 'HU', 'Inde': 'IN', 'Indonésie': 'ID',
'Irak': 'IQ', 'Iran': 'IR', 'Irlande': 'IE', 'Islande': 'IS',
'Israël': 'IL', 'Italie': 'IT', 'Jamaïque': 'JM', 'Japon': 'JP',
'Jordanie': 'JO', 'Kazakhstan': 'KZ', 'Kenya': 'KE', 'Kirghizistan': 'KG',
'Kiribati': 'KI', 'Koweït': 'KW', 'Laos': 'LA', 'Lesotho': 'LS',
'Lettonie': 'LV', 'Liban': 'LB', 'Liberia': 'LR', 'Libye': 'LY',
'Liechtenstein': 'LI', 'Lituanie': 'LT', 'Luxembourg': 'LU', 'Macédoine du Nord': 'MK',
'Madagascar': 'MG', 'Malaisie': 'MY', 'Malawi': 'MW', 'Maldives': 'MV',
'Mali': 'ML', 'Malte': 'MT', 'Maroc': 'MA', 'Îles Marshall': 'MH',
'Maurice': 'MU', 'Mauritanie': 'MR', 'Mexique': 'MX', 'Micronésie': 'FM',
'Moldavie': 'MD', 'Monaco': 'MC', 'Mongolie': 'MN', 'Monténégro': 'ME',
'Mozambique': 'MZ', 'Myanmar': 'MM', 'Namibie': 'NA', 'Nauru': 'NR',
'Népal': 'NP', 'Nicaragua': 'NI', 'Niger': 'NE', 'Nigeria': 'NG',
'Norvège': 'NO', 'Nouvelle-Zélande': 'NZ', 'Oman': 'OM', 'Ouganda': 'UG',
'Ouzbékistan': 'UZ', 'Pakistan': 'PK', 'Palaos': 'PW', 'Panama': 'PA',
'Papouasie-Nouvelle-Guinée': 'PG', 'Paraguay': 'PY', 'Pays-Bas': 'NL', 'Pérou': 'PE',
'Philippines': 'PH', 'Pologne': 'PL', 'Portugal': 'PT', 'Qatar': 'QA',
'Roumanie': 'RO', 'Royaume-Uni': 'GB', 'Russie': 'RU', 'Rwanda': 'RW',
'Saint-Kitts-et-Nevis': 'KN', 'Saint-Marin': 'SM', 'Saint-Vincent-et-les-Grenadines': 'VC',
'Sainte-Lucie': 'LC', 'Îles Salomon': 'SB', 'Samoa': 'WS', 'Sao Tomé-et-Principe': 'ST',
'Sénégal': 'SN', 'Serbie': 'RS', 'Seychelles': 'SC', 'Sierra Leone': 'SL',
'Singapour': 'SG', 'Slovaquie': 'SK', 'Slovénie': 'SI', 'Somalie': 'SO',
'Soudan': 'SD', 'Soudan du Sud': 'SS', 'Sri Lanka': 'LK', 'Suède': 'SE',
'Suisse': 'CH', 'Suriname': 'SR', 'Syrie': 'SY', 'Tadjikistan': 'TJ',
'Tanzanie': 'TZ', 'Tchad': 'TD', 'Tchéquie': 'CZ', 'Thaïlande': 'TH',
'Timor oriental': 'TL', 'Togo': 'TG', 'Tonga': 'TO', 'Trinité-et-Tobago': 'TT',
'Tunisie': 'TN', 'Turkménistan': 'TM', 'Turquie': 'TR', 'Tuvalu': 'TV',
'Ukraine': 'UA', 'Uruguay': 'UY', 'Vanuatu': 'VU', 'Venezuela': 'VE',
'Viêt Nam': 'VN', 'Yémen': 'YE', 'Zambie': 'ZM', 'Zimbabwe': 'ZW',
'États-Unis': 'US',
};
const rows = db.prepare(
`SELECT id, pays_siege, domiciliation FROM plateformes_referentiel WHERE pays_siege IS NOT NULL`
).all();
const stmt = db.prepare(`UPDATE plateformes_referentiel SET domiciliation = ? WHERE id = ?`);
let updated = 0, unmatched = [];
for (const row of rows) {
const iso = NAME_TO_ISO[row.pays_siege];
if (iso && iso !== row.domiciliation) {
stmt.run(iso, row.id);
updated++;
} else if (!iso) {
unmatched.push(row.pays_siege);
}
}
if (updated > 0)
console.log(`[DB] Migration pays_siege → domiciliation : ${updated} ligne(s) mise(s) à jour.`);
if (unmatched.length > 0)
console.warn(`[DB] Migration pays_siege → domiciliation : valeurs non reconnues →`, [...new Set(unmatched)]);
}
// ── Migration : catégories/secteurs par user + associations plateformes/investissements ──
{
// 1. Ajouter user_id sur categories_inv (NULL = global admin)
const colsCatInv = db.prepare("PRAGMA table_info(categories_inv)").all().map(c => c.name);
if (!colsCatInv.includes('user_id')) {
db.exec('ALTER TABLE categories_inv ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
console.log('[DB] categories_inv.user_id ajouté');
}
// 2. Ajouter user_id sur secteurs_inv (NULL = global admin)
const colsSectInv = db.prepare("PRAGMA table_info(secteurs_inv)").all().map(c => c.name);
if (!colsSectInv.includes('user_id')) {
db.exec('ALTER TABLE secteurs_inv ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
console.log('[DB] secteurs_inv.user_id ajouté');
}
// 3. Table associations plateforme ↔ catégories
db.exec(`
CREATE TABLE IF NOT EXISTS plateforme_categories_inv (
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
categorie_id INTEGER NOT NULL REFERENCES categories_inv(id) ON DELETE CASCADE,
PRIMARY KEY (plateforme_id, categorie_id)
)
`);
// 4. Table associations plateforme ↔ secteurs
db.exec(`
CREATE TABLE IF NOT EXISTS plateforme_secteurs_inv (
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
secteur_id INTEGER NOT NULL REFERENCES secteurs_inv(id) ON DELETE CASCADE,
PRIMARY KEY (plateforme_id, secteur_id)
)
`);
// 5. Table associations investissement ↔ catégories
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_categories_inv (
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
categorie_id INTEGER NOT NULL REFERENCES categories_inv(id) ON DELETE CASCADE,
PRIMARY KEY (investissement_id, categorie_id)
)
`);
// 6. Table associations investissement ↔ secteurs
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_secteurs_inv (
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
secteur_id INTEGER NOT NULL REFERENCES secteurs_inv(id) ON DELETE CASCADE,
PRIMARY KEY (investissement_id, secteur_id)
)
`);
console.log('[DB] Tables catégories/secteurs plateforme+investissement OK');
}
// ── Migration : table de configuration SMTP ──────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS smtp_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 587,
secure INTEGER NOT NULL DEFAULT 0,
email TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
allow_unauth INTEGER NOT NULL DEFAULT 0,
app_name TEXT NOT NULL DEFAULT 'Crowdlending Tracker',
app_url TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Migration : identification de l'environnement (Admin > Général) ─────────
// Demande Olivier 05/09/26 : chaque instance (prod / qualification / dev)
// affiche et enregistre son propre environnement, choisi manuellement par un
// admin — aucune inférence automatique possible ici (contrairement à
// appUrl/mcpUrl en dev via import.meta.env.DEV, cf. devOverrides.js : une
// instance de qualification tourne sur un build comme la prod, le mode DEV de
// Vite ne permet donc pas de la distinguer). Table dédiée (plutôt qu'une
// colonne de plus dans smtp_config) et ajoutée à ENV_LOCAL_TABLES ci-dessus :
// une restauration de base copiée depuis un autre environnement ne doit
// jamais réécrire l'étiquette de CET environnement-ci.
db.exec(`
CREATE TABLE IF NOT EXISTS parametres_environnement (
id INTEGER PRIMARY KEY CHECK (id = 1),
environnement TEXT NOT NULL DEFAULT 'developpement'
CHECK (environnement IN ('production', 'qualification', 'developpement')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)`);
// ── Migration : thème de couleur de l'interface (Admin > Général) ───────────
// Demande Olivier 05/09/26 : teinte de l'interface (menus, boutons — distincte
// du mode clair/sombre existant, cf. ThemeContext.jsx) choisie parmi une
// liste de préréglages (frontend/src/styles.css, blocs [data-color-theme]),
// appliquée à TOUS les utilisateurs (contrairement au mode clair/sombre, une
// préférence par utilisateur). But d'Olivier : garder Production dans le
// bleu par défaut mais donner aux autres environnements une couleur
// différente, reconnaissable au premier coup d'œil. Table dédiée (même
// raisonnement que parametres_environnement ci-dessus) et ajoutée à
// ENV_LOCAL_TABLES : ne doit jamais être réécrite par une restauration.
//
// 05/09/26 (suite, même jour) : les 5 thèmes initiaux (bleu/vert/violet/
// orange/bordeaux, couleurs choisies librement) sont remplacés par 10,
// alignés sur les 10 palettes de graphiques de Settings > Apparence
// (cf. project_chart_colors.md) — chaque thème reprend la TEINTE de la
// couleur "Intérêts" de la palette correspondante (même méthode de rotation
// que les 5 thèmes initiaux : S/L de chaque jeton CSS inchangés, seule la
// teinte tourne). SQLite ne permet pas d'altérer un CHECK constraint : la
// table doit être recréée, même stratégie que les migrations perte_definitive/
// prolongation sur investissements plus loin dans ce fichier. Valeur existante
// migrée : 'violet' reste 'violet' (même nom, teinte recalculée depuis la
// palette) ; tout le reste (bleu/vert/orange/bordeaux) retombe sur 'classique'
// — décision explicite d'Olivier pour son propre 'bordeaux' actif, généralisée
// à toute valeur historique sans successeur direct parmi les 10 palettes.
{
const NEW_COLOR_THEMES = ['classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne'];
const schemaTheme = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='parametres_theme'"
).get()?.sql ?? '';
if (schemaTheme && !schemaTheme.includes("'classique'")) {
const oldValue = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get()?.color_theme;
const migratedValue = NEW_COLOR_THEMES.includes(oldValue) ? oldValue : 'classique';
const tempName = '__repair_parametres_theme';
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(`
CREATE TABLE "${tempName}" (
id INTEGER PRIMARY KEY CHECK (id = 1),
color_theme TEXT NOT NULL DEFAULT 'classique'
CHECK (color_theme IN ('classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.prepare(`INSERT INTO "${tempName}" (id, color_theme) VALUES (1, ?)`).run(migratedValue);
db.exec('DROP TABLE parametres_theme');
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO parametres_theme`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
console.log(`[DB] parametres_theme : migré vers les 10 thèmes basés sur les palettes de graphiques (valeur existante '${oldValue}' → '${migratedValue}')`);
} else {
db.exec(`
CREATE TABLE IF NOT EXISTS parametres_theme (
id INTEGER PRIMARY KEY CHECK (id = 1),
color_theme TEXT NOT NULL DEFAULT 'classique'
CHECK (color_theme IN ('classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}
}
db.exec(`INSERT OR IGNORE INTO parametres_theme (id) VALUES (1)`);
// ── Migration : email_verified sur users ─────────────────────────────────────
{
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!userCols.includes('email_verified')) {
// DEFAULT 1 pour ne pas bloquer les comptes existants
db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 1');
console.log('[DB] users.email_verified ajouté');
}
}
// ── Migration : table email_verification_tokens ───────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Migration : table password_reset_tokens ───────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Migrations 2FA ────────────────────────────────────────────────────────────
{
const userCols2 = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!userCols2.includes('totp_secret')) {
db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT');
console.log('[DB] users.totp_secret ajouté');
}
if (!userCols2.includes('totp_enabled')) {
db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0');
console.log('[DB] users.totp_enabled ajouté');
}
}
// Sessions temporaires 2FA (entre /login et /2fa/verify)
db.exec(`
CREATE TABLE IF NOT EXISTS two_fa_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_sess_token ON two_fa_sessions(token)');
// Codes OTP envoyés par email
db.exec(`
CREATE TABLE IF NOT EXISTS two_fa_email_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code TEXT NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_email_uid ON two_fa_email_codes(user_id)');
// Appareils de confiance (30 jours)
db.exec(`
CREATE TABLE IF NOT EXISTS two_fa_trusted_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_token ON two_fa_trusted_devices(token)');
db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_uid ON two_fa_trusted_devices(user_id)');
// ── Migration : user_agent + ip_address sur two_fa_trusted_devices ───────────
{
const devCols = db.prepare('PRAGMA table_info(two_fa_trusted_devices)').all().map(c => c.name);
if (!devCols.includes('user_agent')) {
db.exec('ALTER TABLE two_fa_trusted_devices ADD COLUMN user_agent TEXT');
console.log('[DB] two_fa_trusted_devices.user_agent ajouté');
}
if (!devCols.includes('ip_address')) {
db.exec('ALTER TABLE two_fa_trusted_devices ADD COLUMN ip_address TEXT');
console.log('[DB] two_fa_trusted_devices.ip_address ajouté');
}
}
// ── Migration : last_seen_at sur two_fa_trusted_devices ──────────────────────
{
const devCols2 = db.prepare('PRAGMA table_info(two_fa_trusted_devices)').all().map(c => c.name);
if (!devCols2.includes('last_seen_at')) {
db.exec('ALTER TABLE two_fa_trusted_devices ADD COLUMN last_seen_at TEXT');
console.log('[DB] two_fa_trusted_devices.last_seen_at ajouté');
}
}
console.log('[DB] Migrations 2FA OK');
// ── Table invitations ────────────────────────────────────────────────────────
{
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='invitations'").get();
if (!tables) {
db.exec(`
CREATE TABLE invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations(token)');
console.log('[DB] Table invitations créée');
}
}
// ── Migration : status sur users ─────────────────────────────────────────────
{
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!userCols.includes('status')) {
db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
console.log('[DB] users.status ajouté');
}
}
// ── Table audit_logs ─────────────────────────────────────────────────────────
{
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs'").get();
if (!tables) {
db.exec(`
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
target_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
category TEXT NOT NULL,
details TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit_logs(category)');
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)');
console.log('[DB] Table audit_logs créée');
}
}
// ── Migration : colonnes paramètres généraux dans smtp_config ─────────────
{
const cols = db.prepare("PRAGMA table_info(smtp_config)").all().map(c => c.name);
if (!cols.includes('allow_registration')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN allow_registration INTEGER NOT NULL DEFAULT 1");
console.log('[DB] Colonne smtp_config.allow_registration ajoutée');
}
if (!cols.includes('min_password_length')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8");
console.log('[DB] Colonne smtp_config.min_password_length ajoutée');
}
if (!cols.includes('mcp_url')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN mcp_url TEXT NOT NULL DEFAULT ''");
console.log('[DB] Colonne smtp_config.mcp_url ajoutée');
}
// ── Migration : email sur investisseurs ───────────────────────────────
const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name);
if (!invColsEmail.includes('email')) {
db.exec('ALTER TABLE investisseurs ADD COLUMN email TEXT');
console.log('[DB] Colonne investisseurs.email ajoutée');
}
}
// ── Table notifications ───────────────────────────────────────────────────
{
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'").get();
if (!tables) {
db.exec(`
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'system',
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_notif_user_id ON notifications(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_notif_read ON notifications(user_id, read)');
db.exec('CREATE INDEX IF NOT EXISTS idx_notif_created ON notifications(created_at)');
console.log('[DB] Table notifications créée');
}
}
// ── Tickets support ───────────────────────────────────────────────────────────
{
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
if (!cols.includes('id')) {
db.exec(`
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_number TEXT NOT NULL UNIQUE,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_user_id ON tickets(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_updated ON tickets(updated_at)');
console.log('[DB] Table tickets créée');
}
}
{
const cols = db.prepare("PRAGMA table_info(ticket_messages)").all().map(c => c.name);
if (!cols.includes('id')) {
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_tmsg_ticket_id ON ticket_messages(ticket_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tmsg_created ON ticket_messages(created_at)');
console.log('[DB] Table ticket_messages créée');
}
}
{
const cols = db.prepare("PRAGMA table_info(ticket_attachments)").all().map(c => c.name);
if (!cols.includes('id')) {
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES ticket_messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_tattach_message_id ON ticket_attachments(message_id)');
console.log('[DB] Table ticket_attachments créée');
}
}
{
// Migration : colonne updated_at sur ticket_messages (édition 5 min)
const cols = db.prepare("PRAGMA table_info(ticket_messages)").all().map(c => c.name);
if (!cols.includes('updated_at')) {
db.exec("ALTER TABLE ticket_messages ADD COLUMN updated_at TEXT");
console.log('[DB] ticket_messages: colonne updated_at ajoutée');
}
}
{
// Migration : ticket_type et ticket_pages sur tickets (qualification)
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
if (!cols.includes('ticket_type')) {
db.exec("ALTER TABLE tickets ADD COLUMN ticket_type TEXT");
console.log('[DB] tickets: colonne ticket_type ajoutée');
}
if (!cols.includes('ticket_pages')) {
db.exec("ALTER TABLE tickets ADD COLUMN ticket_pages TEXT");
console.log('[DB] tickets: colonne ticket_pages ajoutée');
}
}
{
// Migration : assigned_to sur tickets
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
if (!cols.includes('assigned_to')) {
db.exec("ALTER TABLE tickets ADD COLUMN assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL");
console.log('[DB] tickets: colonne assigned_to ajoutée');
}
}
// ── Backfill : garantir un profil principal + un compte courant par utilisateur ──
// Certains parcours de création de compte (admin, invitation) ne créaient pas
// systématiquement le profil investisseur principal et/ou son compte courant,
// contrairement à /auth/register. Ce backfill est idempotent (NOT EXISTS partout)
// et s'exécute à chaque démarrage pour rattraper les comptes existants.
{
// 1) Utilisateurs sans aucun investisseur (ex : comptes créés via invitation)
const usersWithoutInvestisseur = db.prepare(`
SELECT id, email, display_name FROM users u
WHERE NOT EXISTS (SELECT 1 FROM investisseurs i WHERE i.user_id = u.id)
`).all();
for (const u of usersWithoutInvestisseur) {
const fullName = u.display_name || u.email.split('@')[0];
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
).run(u.id, fullName, prenom);
console.log(`[DB] backfill: profil investisseur principal créé pour user #${u.id} (${u.email})`);
}
// 2) Utilisateurs ayant des investisseurs mais aucun marqué principal (ex : comptes créés par un admin)
const usersWithoutPrincipal = db.prepare(`
SELECT DISTINCT user_id FROM investisseurs i
WHERE NOT EXISTS (SELECT 1 FROM investisseurs p WHERE p.user_id = i.user_id AND p.is_principal = 1)
`).all();
for (const { user_id } of usersWithoutPrincipal) {
const candidate = db.prepare(`
SELECT id FROM investisseurs WHERE user_id = ? ORDER BY (type = 'famille') DESC, id ASC LIMIT 1
`).get(user_id);
if (candidate) {
db.prepare('UPDATE investisseurs SET is_principal = 1 WHERE id = ?').run(candidate.id);
console.log(`[DB] backfill: investisseur #${candidate.id} marqué principal pour user #${user_id}`);
}
}
// 3) Investisseurs principaux sans compte courant
const principalsWithoutCompte = db.prepare(`
SELECT i.id AS investisseur_id, i.user_id, i.nom
FROM investisseurs i
WHERE i.is_principal = 1
AND NOT EXISTS (SELECT 1 FROM comptes c WHERE c.investisseur_id = i.id)
`).all();
for (const inv of principalsWithoutCompte) {
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(inv.user_id, `Compte courant — ${inv.nom}`, 'compte_courant', inv.investisseur_id);
console.log(`[DB] backfill: compte courant créé pour l'investisseur principal #${inv.investisseur_id} (user #${inv.user_id})`);
}
}
// ── Migration : exclusions de tags hérités du référentiel sur plateformes ──
// Permet à l'utilisateur de retirer une catégorie/secteur hérité du référentiel
// sans que la fusion d'affichage ou un futur push référentiel ne la réinjecte.
{
const platColsExcl = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsExcl.includes('excluded_categories_inv_ids')) {
db.exec("ALTER TABLE plateformes ADD COLUMN excluded_categories_inv_ids TEXT NOT NULL DEFAULT '[]'");
}
if (!platColsExcl.includes('excluded_secteurs_inv_ids')) {
db.exec("ALTER TABLE plateformes ADD COLUMN excluded_secteurs_inv_ids TEXT NOT NULL DEFAULT '[]'");
}
}
// ── Migration : compteur de doublons ignorés sur imports ──────────────────
// Distingue les lignes ignorées car identiques à une ligne déjà en base
// (rows_duplicates) des lignes ignorées pour une autre raison (rows_skipped
// reste le total ; rows_duplicates est un sous-ensemble informatif).
{
const importsCols = db.prepare('PRAGMA table_info(imports)').all().map(c => c.name);
if (!importsCols.includes('rows_duplicates')) {
db.exec('ALTER TABLE imports ADD COLUMN rows_duplicates INTEGER NOT NULL DEFAULT 0');
}
}
// ── Migration : plateforme_id en CASCADE (au lieu de RESTRICT) sur
// depots_retraits et investissements ────────────────────────────────────
// RESTRICT empêchait la suppression complète d'un compte (DELETE FROM users) :
// la cascade users→plateformes (user_id CASCADE) et users→investisseurs→
// depots_retraits/investissements (CASCADE) sont deux branches indépendantes
// de l'arbre de suppression ; SQLite peut supprimer une plateforme avant les
// lignes qui la référencent encore sur l'autre branche, ce qui déclenche le
// RESTRICT (erreur SQLITE_CONSTRAINT_TRIGGER). La protection "impossible de
// supprimer une plateforme qui a encore des données" est déplacée dans la
// route DELETE /api/plateformes/:id (vérification explicite avec message clair).
{
const fixPlateformeCascade = (tableName, indexStatements) => {
const row = db.prepare(
`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
).get(tableName);
if (!row || !/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i.test(row.sql)) return;
const tempName = `__repair_${tableName}`;
const nameRe = new RegExp(
`CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["'\`\\[]?${tableName}["'\`\\]]?`, 'i'
);
if (!nameRe.test(row.sql)) {
console.error(`[DB] migration plateforme_id CASCADE : nom de table non reconnu dans le DDL de "${tableName}", migration ignorée.`);
return;
}
const fixedDdl = row.sql
.replace(nameRe, `CREATE TABLE "${tempName}"`)
.replace(/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i, 'REFERENCES plateformes(id) ON DELETE CASCADE');
const colDefs = db.prepare(`PRAGMA table_info("${tableName}")`).all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL`
).all(tableName);
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM "${tableName}"`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec(`DROP TABLE "${tableName}"`);
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
for (const stmt of indexStatements) db.exec(stmt);
console.log(`[DB] migration : plateforme_id passé en ON DELETE CASCADE sur "${tableName}".`);
};
fixPlateformeCascade('depots_retraits', [
'CREATE INDEX IF NOT EXISTS idx_depret_inv ON depots_retraits(investisseur_id)',
'CREATE INDEX IF NOT EXISTS idx_depret_plat ON depots_retraits(plateforme_id)',
'CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation)',
]);
fixPlateformeCascade('investissements', [
'CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)',
'CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)',
]);
}
// ── Migration : table objectifs ──────────────────────────────────────────
// Objectifs annuels. `type` permet plusieurs natures d'objectifs dans la même
// table : 'versement_annuel' (enveloppe globale de dépôts, par investisseur,
// plateforme_id NULL) et 'capital_investi' (cible de capital investi pour une
// plateforme donnée, par investisseur + plateforme). plateforme_id est NULL
// pour 'versement_annuel', renseigné pour 'capital_investi'. Deux index
// uniques partiels (au lieu d'un seul UNIQUE inline) car SQLite considère
// deux NULL comme distincts : un UNIQUE(investisseur_id,type,annee) global
// n'empêcherait pas plusieurs lignes capital_investi (une par plateforme)
// pour le même investisseur/année — cf. section Paramétrage > Fixation des
// objectifs.
db.exec(`
CREATE TABLE IF NOT EXISTS objectifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'versement_annuel',
annee INTEGER NOT NULL,
montant REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Migration pour les bases existantes créées avant plateforme_id : recrée la
// table (ALTER TABLE ne permet pas de changer une contrainte UNIQUE inline).
{
const objCols = db.prepare('PRAGMA table_info(objectifs)').all().map(c => c.name);
if (!objCols.includes('plateforme_id')) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec('ALTER TABLE objectifs RENAME TO objectifs_old_migration');
db.exec(`
CREATE TABLE objectifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'versement_annuel',
annee INTEGER NOT NULL,
montant REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
INSERT INTO objectifs (id, investisseur_id, plateforme_id, type, annee, montant, notes, created_at, updated_at)
SELECT id, investisseur_id, NULL, type, annee, montant, notes, created_at, updated_at
FROM objectifs_old_migration
`);
db.exec('DROP TABLE objectifs_old_migration');
db.exec('PRAGMA foreign_keys = ON');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)');
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_plateforme ON objectifs(plateforme_id, type, annee)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_objectifs_global_uniq ON objectifs(investisseur_id, type, annee) WHERE plateforme_id IS NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_objectifs_plateforme_uniq ON objectifs(plateforme_id, type, annee) WHERE plateforme_id IS NOT NULL');
// ── Migration : suivi anti-doublon des notifications "données incomplètes" ──
// Stocke la signature (liste triée des champs essentiels manquants) au moment
// de la dernière notification envoyée pour cet investissement, afin que le job
// checkDonneesIncompletes ne notifie à nouveau que si ce manque a changé
// (nouveau champ manquant, champ comblé, ou tout comblé → signature NULL).
{
const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!cols.includes('donnees_incompletes_signature')) {
db.exec('ALTER TABLE investissements ADD COLUMN donnees_incompletes_signature TEXT');
console.log('[DB] investissements: colonne donnees_incompletes_signature ajoutée');
}
}
// ── Migration : table api_keys ───────────────────────────────────────────
// Clés API générées par l'utilisateur pour un accès programmatique/MCP.
// Chaque clé est scopée à un investisseur précis (usage single-user
// multi-investisseur — pas de notion multi-tenant ici). Seul le hash SHA-256
// est stocké ; la valeur en clair n'est montrée qu'une fois à la création.
// `key_prefix` (8 premiers caractères) permet d'identifier une clé dans
// l'UI sans jamais réafficher la valeur complète.
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT 'read',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
revoked_at TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
// ── Migration : clés API à scope "Famille et entreprises" ───────────────
// Une clé peut désormais couvrir tous les investisseurs du foyer plutôt
// qu'un seul (agrégation, en miroir du scope=all déjà utilisé par le
// frontend JWT). `investisseur_id` reste NOT NULL pour ne pas toucher à la
// contrainte existante : quand scope_all=1, la route de création force
// investisseur_id à pointer vers l'investisseur principal (ancrage FK),
// mais les routes /api/v1/* ignorent alors cette valeur au profit d'un
// filtre "tous les investisseurs de ce user_id" — voir apiKey.js et
// routes/v1/*.js. Seul le profil principal peut créer une clé scope_all=1
// (enforcement côté serveur dans routes/apiKeys.js, pas seulement l'UI).
{
const cols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
if (!cols.includes('scope_all')) {
db.exec('ALTER TABLE api_keys ADD COLUMN scope_all INTEGER NOT NULL DEFAULT 0');
console.log('[DB] api_keys: colonne scope_all ajoutée');
}
}
// ── Migration : v_interets_annuels — inclure les "Intérêts plateforme" ──
// Nouveau type de remboursement (intérêts versés par une plateforme, hors
// investissement précis, rattachés via bonus_plateforme_id/bonus_investisseur_id
// comme les bonus). Contrairement aux bonus (cashback non imposable), ce sont
// de vrais intérêts fiscalisés : ils doivent compter dans le récap annuel au
// même titre que les intérêts d'investissement classiques.
{
const viewRow = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='view' AND name='v_interets_annuels'"
).get();
if (viewRow && !viewRow.sql.includes('interets_plateforme')) {
db.exec('DROP VIEW IF EXISTS v_interets_annuels');
db.exec(`
CREATE VIEW v_interets_annuels AS
SELECT investisseur_id, annee,
SUM(interets_bruts) AS interets_bruts,
SUM(prelev_sociaux) AS prelev_sociaux,
SUM(prelev_forfaitaire) AS prelev_forfaitaire,
SUM(net_recu) AS net_recu
FROM (
SELECT i.investisseur_id AS investisseur_id,
substr(r.date_remb,1,4) AS annee,
r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE r.statut IN ('paye','partiel') AND r.type = 'normal'
UNION ALL
SELECT r.bonus_investisseur_id AS investisseur_id,
substr(r.date_remb,1,4) AS annee,
r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu
FROM remboursements r
WHERE r.statut IN ('paye','partiel') AND r.type = 'interets_plateforme'
)
GROUP BY investisseur_id, annee
`);
console.log('[DB] v_interets_annuels: vue recréée (inclut interets_plateforme)');
}
}
// ── Migration : ajout du statut 'perte_definitive' ──────────────────────
// Nouvelle valeur d'enum pour déclarer explicitement qu'un capital est
// définitivement perdu (irrécouvrable), distincte de 'en_retard'/'procedure'
// qui restent potentiellement recouvrables. SQLite ne permet pas d'altérer un
// CHECK constraint : on recrée la table avec le nouveau CHECK, même stratégie
// que la migration 'defaut' → 'en_retard' plus haut. Placé en toute fin de
// fichier (après toutes les migrations ADD COLUMN de la table investissements)
// pour que PRAGMA table_info reflète bien la totalité des colonnes, y compris
// sur une base fraîchement créée depuis schema.sql.
{
const schemaInvPerte = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'"
).get()?.sql ?? '';
if (schemaInvPerte && !schemaInvPerte.includes("'perte_definitive'")) {
const tempName = '__repair_investissements_perte';
// Le nom de table peut être stocké avec ou sans guillemets dans
// sqlite_master.sql selon qu'une précédente migration (ex. 'defaut' →
// 'en_retard') a déjà renommé la table via ALTER TABLE ... RENAME TO
// (SQLite conserve alors le nom entre guillemets dans le DDL stocké) —
// le regex doit couvrir les deux formes, sinon le remplacement échoue
// silencieusement et fixedDdl retente de créer "investissements" alors
// qu'elle existe déjà (SqliteError: table "investissements" already exists).
const fixedDdl = schemaInvPerte
.replace(/CREATE TABLE\s+"?investissements"?/, `CREATE TABLE "${tempName}"`)
.replace(",'cloture'))", ",'cloture','perte_definitive'))");
if (fixedDdl.includes(`"${tempName}"`) && fixedDdl.includes("'perte_definitive'")) {
const colDefs = db.prepare('PRAGMA table_info(investissements)').all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM investissements`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE investissements');
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)');
console.log("[DB] investissements: statut 'perte_definitive' ajouté au CHECK constraint");
} else {
console.error("[DB] Migration perte_definitive : nom de table ou motif ',cloture))' introuvable dans le DDL — migration ignorée, à vérifier manuellement");
}
}
}
// ── Migration : table investissement_pertes ──────────────────────────────
// Trace chaque déclaration de perte définitive : date de constat, montant
// perdu (préfilli côté frontend avec le capital restant dû mais éditable pour
// une perte partielle si une partie du capital a déjà été recouvrée) et motif
// obligatoire. Distincte de investissement_historique (piste d'audit générique
// auto-détectée) : ici on conserve la donnée structurée exploitée par le
// calcul fiscal (case 2TY) et par les KPI de capital investi/restant dû.
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_pertes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
date_effet TEXT NOT NULL,
montant_perte REAL NOT NULL CHECK(montant_perte >= 0),
ancien_statut TEXT,
motif TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_pertes_inv ON investissement_pertes(investissement_id)');
// ── Migration : ajout du statut 'prolongation' ──────────────────────────
// Nouvelle valeur d'enum posée automatiquement par POST /revisions quand une révision
// des conditions change la date cible d'un dossier en_retard/procedure (renégociation
// avec échéancier modifié) : le dossier sort temporairement du scope "à risque" jusqu'à
// ce que la nouvelle date cible soit à son tour dépassée (cf. jobs/autoStatut.js qui le
// repasse alors en_retard). Même stratégie de recréation de table que 'perte_definitive'
// ci-dessus (SQLite ne permet pas d'altérer un CHECK constraint). Placé après cette
// dernière migration pour bénéficier du même schemaInv déjà à jour (CHECK contenant
// déjà 'perte_definitive' à ce stade, sur une base existante comme sur une base neuve).
{
const schemaInvProlong = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'"
).get()?.sql ?? '';
if (schemaInvProlong && !schemaInvProlong.includes("'prolongation'")) {
const tempName = '__repair_investissements_prolongation';
// Cf. migration perte_definitive plus haut : le nom de table peut être stocké
// avec ou sans guillemets dans sqlite_master.sql selon l'historique des ALTER
// TABLE ... RENAME TO déjà exécutés — le regex doit couvrir les deux formes.
const fixedDdl = schemaInvProlong
.replace(/CREATE TABLE\s+"?investissements"?/, `CREATE TABLE "${tempName}"`)
.replace(",'perte_definitive'))", ",'perte_definitive','prolongation'))");
if (fixedDdl.includes(`"${tempName}"`) && fixedDdl.includes("'prolongation'")) {
const colDefs = db.prepare('PRAGMA table_info(investissements)').all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM investissements`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE investissements');
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)');
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)');
console.log("[DB] investissements: statut 'prolongation' ajouté au CHECK constraint");
} else {
console.error("[DB] Migration prolongation : nom de table ou motif \",'perte_definitive'))\" introuvable dans le DDL — migration ignorée, à vérifier manuellement");
}
}
}
// ── Migration : table documents ───────────────────────────────────────────
// Pièces jointes de l'utilisateur (compte de login) — pour l'instant
// uniquement des documents rattachés à un investissement (entity_type
// ='investissement', entity_id=investissements.id), mais entity_type/
// entity_id/categorie restent génériques pour accueillir d'autres types de
// documents à l'avenir (demande Olivier 29/08/26). Quota de stockage et de
// nombre de documents par utilisateur — valeur fixe globale pour l'instant,
// cf. QUOTA_BYTES/QUOTA_COUNT dans backend/src/routes/documents.js.
db.exec(`
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
categorie TEXT NOT NULL DEFAULT 'investissement',
entity_type TEXT,
entity_id INTEGER,
nom_affichage TEXT NOT NULL,
nom_original TEXT NOT NULL,
extension TEXT NOT NULL,
mime_type TEXT,
taille_octets INTEGER NOT NULL,
filename TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_documents_entity ON documents(entity_type, entity_id)');
// ── Migration : quotas documentaires configurables (Admin > Général) ──────
// Demande Olivier 29/08/26 : plutôt qu'une valeur fixe codée en dur dans
// backend/src/routes/documents.js, le quota (par utilisateur — même valeur
// pour tous les comptes, pas de personnalisation individuelle pour l'instant)
// est stocké sur la ligne unique de smtp_config, comme le reste des
// paramètres généraux (app_name, app_url...), et lu à la volée par
// documents.js à chaque requête. Défauts alignés sur les anciennes
// constantes : 5 Go / 500 documents.
const smtpCols = db.prepare("PRAGMA table_info(smtp_config)").all().map(c => c.name);
if (!smtpCols.includes('documents_quota_bytes')) {
db.exec('ALTER TABLE smtp_config ADD COLUMN documents_quota_bytes INTEGER NOT NULL DEFAULT 5368709120'); // 5 Go (Gio)
}
if (!smtpCols.includes('documents_quota_count')) {
db.exec('ALTER TABLE smtp_config ADD COLUMN documents_quota_count INTEGER NOT NULL DEFAULT 500');
}
// ── Migration : catégorie de plateforme (PSFP / PSAN) ──────────────────────
// Distingue les plateformes "Prestataire de Services de Financement
// Participatif" (crowdfunding classique) des "Prestataire de Services sur
// Actifs Numériques" (crypto). Valeur définie au niveau du référentiel admin
// et répliquée sur les plateformes utilisateur à la création ; modifiable
// ensuite par l'utilisateur (Paramètres > Plateformes) comme les autres
// champs hérités (cf. HERITABLE_FIELDS / overridden_fields). Demande Olivier
// 30/08/26.
{
const refColsCatPlat = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name);
if (!refColsCatPlat.includes('categorie_plateforme')) {
db.exec("ALTER TABLE plateformes_referentiel ADD COLUMN categorie_plateforme TEXT NOT NULL DEFAULT 'psfp'");
}
const platColsCatPlat = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsCatPlat.includes('categorie_plateforme')) {
db.exec("ALTER TABLE plateformes ADD COLUMN categorie_plateforme TEXT NOT NULL DEFAULT 'psfp'");
}
}
// ── Migration : frais liés à la plateforme ──────────────────────────────────
// Peu de plateformes appliquent des frais ; ce champ permet de capter ce
// comportement applicatif. Valeur définie au niveau du référentiel admin et
// répliquée sur les plateformes utilisateur à la création ; modifiable
// ensuite par l'utilisateur (Paramètres > Plateformes) comme les autres
// champs hérités (cf. HERITABLE_FIELDS / overridden_fields). Demande Olivier
// 30/08/26.
{
const refColsFraisPlat = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name);
if (!refColsFraisPlat.includes('frais_plateforme')) {
db.exec("ALTER TABLE plateformes_referentiel ADD COLUMN frais_plateforme TEXT NOT NULL DEFAULT 'aucun'");
}
const platColsFraisPlat = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsFraisPlat.includes('frais_plateforme')) {
db.exec("ALTER TABLE plateformes ADD COLUMN frais_plateforme TEXT NOT NULL DEFAULT 'aucun'");
}
}
// ── Migration : frais d'opération (Étape 1 — fondations) ────────────────────
// Modélise les frais facturés par certaines plateformes (frais d'entrée,
// frais de gestion, frais sur distribution) : un montant TTC unique par
// événement (pas de détail HT/TVA, non pertinent pour un particulier).
// - remboursements.frais_ttc : montant de frais prélevé sur CE coupon,
// vient réduire la base imposable au même titre que taxe_locale
// (imposition = f(interets_bruts - frais_ttc)), cf. feedback Olivier
// 30/08/26 sur l'avis d'opéré/notice de distribution Fundora (Kyoseil AM).
// - frais_operations : table de traçabilité pour tout frais, qu'il soit
// prélevé "à la source" (souscription ou coupon, aucun mouvement de
// trésorerie réel) ou payé en "versement complémentaire" (porte-monnaie
// de la plateforme ou compte courant du détenteur — dans ce dernier cas
// sans impact sur le solde du porte-monnaie). Pas encore d'UI ni de route
// de création automatique à ce stade : fondations DB + formule fiscale
// uniquement, le reste (étapes souscription/remboursement/modale dédiée)
// viendra dans une prochaine passe.
{
const rembColsFraisTtc = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembColsFraisTtc.includes('frais_ttc')) {
db.exec('ALTER TABLE remboursements ADD COLUMN frais_ttc REAL NOT NULL DEFAULT 0');
}
}
db.exec(`
CREATE TABLE IF NOT EXISTS frais_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
remboursement_id INTEGER REFERENCES remboursements(id) ON DELETE CASCADE,
montant REAL NOT NULL,
mode_reglement TEXT NOT NULL DEFAULT 'source'
CHECK(mode_reglement IN ('source','portefeuille','compte_courant')),
compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL,
date_operation TEXT NOT NULL,
origine TEXT NOT NULL DEFAULT 'manuel'
CHECK(origine IN ('souscription','remboursement','manuel')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
// ── Migration : catégories de frais (31/08/26, demande Olivier) ─────────────
// `origine` (souscription/remboursement/manuel) reste un détail technique (comment la
// ligne a été créée) ; `categorie` devient le classement visible côté utilisateur, utile
// pour le reporting "Frais payés" de l'Étape 5 : souscription (frais d'entrée, unique par
// investissement — lu/écrit uniquement depuis l'étape "Frais" du formulaire de
// création/modification) / gestion / distribution (liée à un remboursement) / autre
// (saisie libre depuis le bloc "Frais d'opération" de la fiche investissement).
{
const fraisOpCols = db.prepare('PRAGMA table_info(frais_operations)').all().map(c => c.name);
if (!fraisOpCols.includes('categorie')) {
db.exec("ALTER TABLE frais_operations ADD COLUMN categorie TEXT NOT NULL DEFAULT 'autre'");
// Rattrapage pour les lignes déjà créées avant l'ajout de cette colonne (tests Olivier,
// 30-31/08/26) : on déduit la catégorie la plus probable depuis `origine`.
db.exec(`
UPDATE frais_operations SET categorie = CASE origine
WHEN 'souscription' THEN 'souscription'
WHEN 'remboursement' THEN 'distribution'
ELSE 'autre'
END
`);
}
}
// ── Migration : mode_reglement 'remboursement' (frais de distribution liés
// automatiquement à un remboursement — Étape 4, 31/08/26) ───────────────────
// SQLite ne permet pas d'altérer un CHECK constraint en place (better-sqlite3
// bloque writable_schema) → même stratégie de reconstruction de table que pour
// investissements/plateformes/remboursements ailleurs dans ce fichier.
{
const schemaFraisOp = db.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='frais_operations'"
).get()?.sql ?? '';
if (schemaFraisOp.includes("mode_reglement IN ('source','portefeuille','compte_courant')")) {
const tempName = '__repair_frais_operations';
const fixedDdl = schemaFraisOp
.replace(/CREATE TABLE frais_operations\b/, `CREATE TABLE "${tempName}"`)
.replace(
"mode_reglement IN ('source','portefeuille','compte_courant')",
"mode_reglement IN ('source','portefeuille','compte_courant','remboursement')",
);
const colDefs = db.prepare('PRAGMA table_info(frais_operations)').all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='frais_operations' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM frais_operations`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE frais_operations');
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO frais_operations`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
}
}
// ── Migration : Assistant IA embarqué (04/09/26) ──────────────────────────
// Chat conversationnel branché sur les mêmes outils lecture seule que le
// serveur MCP externe (mcp-server/tools.js, réutilisé tel quel — jamais
// modifié — via un pont MCP in-process, voir backend/src/ai/mcpBridge.js).
// Contrôle d'accès à 3 niveaux : ai_config.actif (global), users.ai_enabled
// (par utilisateur, décidé par l'admin), ai_providers.actif (par
// fournisseur). Deux adaptateurs prévus dès le départ (type 'anthropic'
// natif et 'openai_compatible', ce dernier pouvant pointer vers OpenAI,
// Mistral, un LLM local, ou OpenRouter via base_url) — seul l'adaptateur
// Anthropic est implémenté dans cette première passe. Voir
// backend/src/routes/ai.js et project_ia_embarquee.md (mémoire projet) pour
// le détail de l'architecture.
{
const usersColsAi = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!usersColsAi.includes('ai_enabled')) {
db.exec('ALTER TABLE users ADD COLUMN ai_enabled INTEGER NOT NULL DEFAULT 0');
}
}
db.exec(`
CREATE TABLE IF NOT EXISTS ai_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('anthropic','openai_compatible')),
base_url TEXT,
modele TEXT NOT NULL,
api_key_chiffree TEXT NOT NULL,
actif INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_providers_actif ON ai_providers(actif)');
db.exec(`
CREATE TABLE IF NOT EXISTS ai_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
actif INTEGER NOT NULL DEFAULT 0,
provider_defaut_id INTEGER REFERENCES ai_providers(id) ON DELETE SET NULL,
quota_quotidien INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`INSERT OR IGNORE INTO ai_config (id, actif) VALUES (1, 0)`);
db.exec(`
CREATE TABLE IF NOT EXISTS ai_usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
provider_id INTEGER REFERENCES ai_providers(id) ON DELETE SET NULL,
modele TEXT,
tokens_entree INTEGER,
tokens_sortie INTEGER,
outils_appeles TEXT,
erreur TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_usage_user ON ai_usage_log(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_usage_date ON ai_usage_log(created_at)');
// Clés API internes auto-provisionnées pour l'assistant IA (une par couple
// user/investisseur, jamais affichées dans Mon compte → Clés API) : lui
// donnent accès à /api/v1 exactement comme un client MCP externe, en
// réutilisant l'infrastructure api_keys existante sans la dupliquer — voir
// backend/src/ai/internalApiKey.js.
{
const apiKeysCols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
if (!apiKeysCols.includes('internal')) {
db.exec('ALTER TABLE api_keys ADD COLUMN internal INTEGER NOT NULL DEFAULT 0');
}
}
// ── Réapplication des tables locales préservées avant une restauration ──
// (cf. feedback_restore_preserve_env_local.md). Placé ici, après toutes les
// migrations api_keys/ai_providers/ai_config ci-dessus, pour que le schéma
// de la base fraîchement restaurée soit à jour avant qu'on y réinjecte le
// contenu propre à CET environnement (sinon une colonne migrée seulement en
// aval de ce point serait silencieusement perdue lors de la réinsertion).
if (pendingRestorePreserve) {
try {
const restoreEnvLocal = db.transaction(() => {
for (const table of ENV_LOCAL_TABLES) {
const rows = pendingRestorePreserve[table];
if (!rows) continue; // table absente de l'environnement d'avant restauration
db.exec(`DELETE FROM ${table}`);
if (!rows.length) continue;
const currentCols = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name));
// Ne garder que les colonnes existant réellement dans le schéma courant :
// un export source plus ancien ou plus récent peut avoir un jeu de
// colonnes légèrement différent.
const usableCols = Object.keys(rows[0]).filter(c => currentCols.has(c));
const placeholders = usableCols.map(() => '?').join(',');
const insert = db.prepare(`INSERT INTO ${table} (${usableCols.join(',')}) VALUES (${placeholders})`);
for (const row of rows) insert.run(...usableCols.map(c => row[c]));
}
});
restoreEnvLocal();
console.log('[restore] Tables locales (api_keys/ai_providers/ai_config/parametres_environnement/parametres_theme) préservées et réappliquées après restauration.');
} catch (e) {
console.error('[restore] Échec de la réapplication des tables locales préservées :', e.message);
}
pendingRestorePreserve = null;
}
// Conversations de l'assistant IA — plusieurs fils nommés par couple
// user/investisseur (même granularité de scope que le reste de l'appli, cf.
// requireInvestisseur), consultables et supprimables individuellement
// (page dédiée /assistant-ia + widget flottant, qui partagent la même
// conversation via son id). titre : dérivé du premier message utilisateur
// à la création (voir routes/ai.js), jamais renommé automatiquement ensuite.
db.exec(`
CREATE TABLE IF NOT EXISTS ai_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE CASCADE,
titre TEXT NOT NULL DEFAULT 'Nouvelle conversation',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_conversations_scope ON ai_conversations(user_id, investisseur_id, updated_at DESC)');
// Messages de l'assistant IA — rattachés à une conversation (ai_conversations).
// outils_appeles : JSON (tableau de noms d'outil) pour les messages assistant
// uniquement, sinon NULL. Voir backend/src/ai/conversationHistory.js.
// user_id/investisseur_id restent présents sur chaque message (redondants
// avec ai_conversations mais pratiques pour des requêtes directes futures,
// et c'est le schéma d'origine avant l'introduction du multi-conversations).
db.exec(`
CREATE TABLE IF NOT EXISTS ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER REFERENCES ai_conversations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user','assistant')),
content TEXT NOT NULL,
outils_appeles TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Migration : ajoute conversation_id si la table ai_messages existait déjà
// (créée avant l'introduction du multi-conversations, 04/09/26).
{
const aiMsgCols = db.prepare('PRAGMA table_info(ai_messages)').all().map(c => c.name);
if (!aiMsgCols.includes('conversation_id')) {
db.exec('ALTER TABLE ai_messages ADD COLUMN conversation_id INTEGER REFERENCES ai_conversations(id) ON DELETE CASCADE');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages(conversation_id, id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_thread ON ai_messages(user_id, investisseur_id, created_at)');
// Backfill : les messages enregistrés avant l'introduction du
// multi-conversations n'ont pas de conversation_id — on les regroupe dans
// une conversation unique par couple user/investisseur pour ne rien perdre
// de l'historique existant. Idempotent (ne trouve plus rien à faire une
// fois exécuté, puisque conversation_id n'est alors plus jamais NULL).
{
const orphanScopes = db.prepare(`
SELECT DISTINCT user_id, investisseur_id FROM ai_messages WHERE conversation_id IS NULL
`).all();
for (const { user_id, investisseur_id } of orphanScopes) {
const firstUserMsg = db.prepare(`
SELECT content FROM ai_messages
WHERE user_id = ? AND investisseur_id IS ? AND conversation_id IS NULL AND role = 'user'
ORDER BY id ASC LIMIT 1
`).get(user_id, investisseur_id);
const rawTitre = firstUserMsg ? firstUserMsg.content.replace(/\s+/g, ' ').trim() : 'Conversation';
const titre = rawTitre.length > 60 ? rawTitre.slice(0, 57) + '…' : (rawTitre || 'Conversation');
const { lastInsertRowid: convId } = db.prepare(`
INSERT INTO ai_conversations (user_id, investisseur_id, titre) VALUES (?, ?, ?)
`).run(user_id, investisseur_id, titre);
db.prepare(`
UPDATE ai_messages SET conversation_id = ?
WHERE user_id = ? AND investisseur_id IS ? AND conversation_id IS NULL
`).run(convId, user_id, investisseur_id);
}
}
// ── Migration : Workspaces (extension multi-workspace, 08/09/26) ─────────
// Transforme l'app en socle commun + "workspaces" activables/désactivables
// par utilisateur, gouvernés par l'admin — cf. project_workspaces_transformation.md
// (mémoire projet) pour le plan complet. Phase 0 : catalogue global
// (`workspaces`) + octroi (admin) / activation (utilisateur) par compte
// (`user_workspaces`). Le workspace "crowdlending" représente l'app
// actuelle et est accordé + activé automatiquement à tous les comptes
// existants (backfill ci-dessous) et à tout nouveau compte (cf.
// grantDefaultWorkspace, appelée depuis auth.js /register, admin.js POST
// /users et invitations.js /:token/register — mêmes 3 points que la
// création auto de l'investisseur principal). Volontairement PAS ajouté à
// ENV_LOCAL_TABLES : ce sont des données métier normales (comme
// investissements), pas des réglages propres à un environnement — une
// restauration doit les écraser comme le reste.
db.exec(`
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
nom TEXT NOT NULL,
libelle_menu TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'private_equity',
actif_global INTEGER NOT NULL DEFAULT 1,
ordre INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// ── Migration : workspaces.type (19/09/26) ───────────────────────────────
// Jusqu'ici, TOUT le routage crowdlending/PE (frontend ET backend — plus de
// 20 endroits, cf. project_workspaces_transformation.md) reposait sur une
// comparaison littérale `slug === 'crowdlending'` : le slug — pourtant un
// simple identifiant technique choisi librement par l'admin (regex
// minuscules/chiffres/tirets, cf. adminWorkspaces.js) — portait donc
// implicitement toute la sémantique "quel modèle de page afficher ?".
// Question d'Olivier (19/09/26) : comment le programme reconnaît-il qu'il
// doit utiliser les pages Crowdlending ou Private Equity, sachant que
// l'admin choisit juste un nom ? Réponse à l'époque : aucune vraie
// typologie, seulement "crowdlending" vs "tout le reste" — décision
// d'Olivier du même jour : corriger ça maintenant, dans tout le code.
// `type` porte désormais cette sémantique explicitement ('crowdlending' ou
// 'private_equity' à ce jour — toute valeur non 'crowdlending' continue,
// pour l'instant, à être traitée comme "PE" côté UI, en attendant qu'un
// 3e modèle de page existe réellement). Choisi à la création d'un
// workspace (formulaire admin, cf. adminWorkspaces.js), immutable ensuite
// — même régime que `slug`, pour la même raison : en changer sous un
// workspace déjà peuplé de données changerait silencieusement quel modèle
// de page/API s'applique à ces données. DEFAULT 'private_equity' ci-dessus
// : filet de sécurité reproduisant le comportement historique implicite
// ("pas crowdlending" = PE) si jamais une ligne était insérée sans passer
// par l'API ; l'API elle-même exige toujours une valeur explicite.
{
const wsCols = db.prepare('PRAGMA table_info(workspaces)').all().map(c => c.name);
if (!wsCols.includes('type')) {
db.exec("ALTER TABLE workspaces ADD COLUMN type TEXT NOT NULL DEFAULT 'private_equity'");
}
// Backfill volontairement INCONDITIONNEL (pas seulement juste après l'ALTER ci-dessus) :
// le seul workspace qui ait jamais eu un sens "crowdlending" est celui de slug
// 'crowdlending' (seed ci-dessous, protégé, non supprimable) — tous les autres (aujourd'hui :
// 'private-equity') étaient déjà traités comme "PE" par l'ancien code, donc reçoivent le
// DEFAULT ci-dessus sans rien faire. Auto-réparateur à chaque démarrage (20/09/26) : une
// version antérieure de cette migration ne faisait ce backfill qu'au moment de l'ALTER, donc
// un redémarrage ayant appliqué l'ALTER avant la correction d'un bug dans cette ligne laissait
// le workspace crowdlending bloqué sur le DEFAULT 'private_equity' — sans nouveau redémarrage
// "à vide" pour le rattraper, ce cas ne pouvait jamais se corriger. Cette ligne s'exécute donc
// désormais à chaque démarrage, sans condition, et ne fait rien dès que le type est déjà bon.
db.exec("UPDATE workspaces SET type = 'crowdlending' WHERE slug = 'crowdlending' AND type <> 'crowdlending'");
}
// Au plus UN workspace de type 'crowdlending' (l'app entière suppose ce
// singleton — resolveActiveWorkspaceId, grantDefaultWorkspace, etc. font
// un .get() qui prend la première ligne trouvée). Index partiel (SQLite
// supporte les index UNIQUE avec clause WHERE) : ne contraint que le type
// 'crowdlending', laisse 'private_equity' libre en plusieurs exemplaires
// si Olivier veut un jour plusieurs workspaces PE distincts.
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_type_crowdlending_singleton
ON workspaces(type) WHERE type = 'crowdlending'
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_workspaces (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
granted_by_admin INTEGER NOT NULL DEFAULT 0,
active_by_user INTEGER NOT NULL DEFAULT 1,
granted_at TEXT,
PRIMARY KEY (user_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(user_id)');
// ── Migration : couleur de thème par (utilisateur, workspace) (17/09/26) ──
// Permet à l'utilisateur de personnaliser la couleur d'accent par espace de
// travail (demande Olivier) : NULL = hérite de la couleur de l'application
// (personnelle si définie via user_preferences.color_theme, sinon défaut
// admin) — même énumération que color_theme (cf. COLOR_THEME_VALUES,
// routes/preferences.js). Stocké sur user_workspaces (PK user_id+
// workspace_id) plutôt que sur workspaces : c'est un réglage personnel par
// compte, pas un réglage global de l'espace. Cf. AppearanceSection.jsx,
// WorkspaceContext.jsx et UiContext.jsx (workspaceColorOverride).
{
const uwCols = db.prepare('PRAGMA table_info(user_workspaces)').all().map(c => c.name);
if (!uwCols.includes('color_theme')) {
db.exec('ALTER TABLE user_workspaces ADD COLUMN color_theme TEXT');
}
}
// Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE).
db.prepare(`
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre)
VALUES ('crowdlending', 'Crowdlending', 'Crowdlending', 'Suivi de portefeuille crowdlending', 'crowdlending', 1, 0)
`).run();
/** Accorde + active le workspace par défaut (crowdlending) à un utilisateur.
* Appelée à la création d'un compte (auth.js /register, admin.js POST
* /users, invitations.js /:token/register) — même logique que la création
* auto du profil investisseur principal à ces 3 mêmes endroits, pour
* qu'aucun nouveau compte ne se retrouve sans accès à l'app actuelle. */
export function grantDefaultWorkspace(userId) {
const ws = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (!ws) return;
db.prepare(`
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
VALUES (?, ?, 1, 1, datetime('now'))
`).run(userId, ws.id);
}
// Backfill : tout utilisateur existant sans accès au workspace crowdlending
// (comptes créés avant cette migration) le reçoit automatiquement — ne doit
// rien casser du fonctionnement actuel. Idempotent (INSERT OR IGNORE sur la
// PK composite user_id/workspace_id).
{
const wsBackfill = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsBackfill) {
db.prepare(`
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
SELECT id, ?, 1, 1, datetime('now') FROM users
`).run(wsBackfill.id);
}
}
// ── Migration : Workspaces sur le référentiel plateformes (08/09/26) ─────
// Chaque entrée du référentiel (plateformes_referentiel) est désormais
// rattachée à un ou plusieurs workspaces (relation N-N, même patron que
// referentiel_categories_inv/referentiel_secteurs_inv). Demande d'Olivier :
// réaffectation obligatoire vers "crowdlending" pour ce qui existe déjà en
// base, et au moins une affectation exigée à la création (validé côté API
// par le schéma Zod de routes/referentiel.js, min(1) sur workspace_ids).
// Placé ici (après la création de `workspaces` ci-dessus) pour garantir
// que la table existe déjà. Cf. project_workspaces_transformation.md.
db.exec(`
CREATE TABLE IF NOT EXISTS referentiel_workspaces (
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
PRIMARY KEY (referentiel_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ref_workspaces_ws ON referentiel_workspaces(workspace_id)');
// Backfill idempotent : toute entrée référentiel sans aucune affectation
// (comptes créés avant cette migration, ou entrées jamais touchées depuis)
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id)
SELECT pr.id, ?
FROM plateformes_referentiel pr
WHERE NOT EXISTS (SELECT 1 FROM referentiel_workspaces rw WHERE rw.referentiel_id = pr.id)
`).run(wsCrowdlending.id);
}
}
// ── Migration : Workspaces sur les plateformes utilisateur (08/09/26) ────
// Extension du même principe que le référentiel ci-dessus : chaque
// plateforme UTILISATEUR (table `plateformes`, distincte du référentiel
// admin) peut désormais être rattachée à un ou plusieurs workspaces (N-N,
// même patron `xxx_workspaces`). Décision d'Olivier (AskUserQuestion,
// 08/09/26) : un compte sur une plateforme peut servir à la fois au
// crowdlending et à un futur workspace Private Equity — pas de colonne
// unique comme initialement envisagé pour l'Étape 2 du retrofit général.
// Backfill : toute plateforme déjà en base est rattachée à "crowdlending".
// Cf. project_workspaces_transformation.md.
db.exec(`
CREATE TABLE IF NOT EXISTS plateforme_workspaces (
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
PRIMARY KEY (plateforme_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_plat_workspaces_ws ON plateforme_workspaces(workspace_id)');
// Backfill idempotent : toute plateforme utilisateur sans aucune affectation
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id)
SELECT p.id, ?
FROM plateformes p
WHERE NOT EXISTS (SELECT 1 FROM plateforme_workspaces pw WHERE pw.plateforme_id = p.id)
`).run(wsCrowdlending.id);
}
}
// ── Migration : workspace_id sur depots_retraits (17/09/26) ─────────────
// Contrairement à plateformes/plateformes_referentiel (relation N-N — un compte
// pouvant servir plusieurs workspaces à la fois), Olivier a tranché le 17/09/26
// pour une colonne UNIQUE sur depots_retraits : chaque mouvement de trésorerie
// est "flêché" vers un seul workspace précis. Démarre, table par table, l'Étape 1
// du retrofit workspace_id documenté dans project_workspaces_transformation.md —
// dépôts/retraits en premier, à la demande explicite d'Olivier (remboursements
// ensuite). Colonne nullable, backfill de l'existant vers "crowdlending".
{
const drCols2 = db.prepare('PRAGMA table_info(depots_retraits)').all().map(c => c.name);
if (!drCols2.includes('workspace_id')) {
db.exec('ALTER TABLE depots_retraits ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_depret_workspace ON depots_retraits(workspace_id)');
// Backfill idempotent : tout dépôt/retrait sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending2) {
db.prepare('UPDATE depots_retraits SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending2.id);
}
}
// ── Migration : workspace_id sur remboursements (17/09/26) ──────────────
// Suite du retrofit workspace_id (project_workspaces_transformation.md),
// table par table à la demande d'Olivier : après depots_retraits, les
// remboursements. Même principe : colonne unique nullable, backfill de
// l'existant vers "crowdlending".
{
const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols.includes('workspace_id')) {
db.exec('ALTER TABLE remboursements ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_workspace ON remboursements(workspace_id)');
// Backfill idempotent : tout remboursement sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending3) {
db.prepare('UPDATE remboursements SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending3.id);
}
}
// ── Investissements Private Equity (17/09/26) ────────────────────────────
// Nouveau modèle de données, volontairement séparé de `investissements`
// (cf. project_workspaces_transformation.md) : un deal PE (ex. Fundora) n'a
// ni échéancier connu à l'avance, ni taux d'intérêt fixe — c'est un montant
// unique investi (même principe que le crowdlending, décision Olivier
// 17/09/26 : le porte-monnaie diminue d'un coup, pas de suivi souscrit/
// décaissé), valorisé périodiquement par le fonds, avec un objectif de
// performance hétérogène selon les deals (multiple "x4,5" ou fourchette de
// taux annuel "9-12 %/an") — on le stocke donc en texte libre plutôt que
// d'essayer de le structurer. Table neuve : workspace_id est NOT NULL dès
// la création, pas besoin du nullable+backfill utilisé pour les tables
// préexistantes (depots_retraits, remboursements).
db.exec(`
CREATE TABLE IF NOT EXISTS investissements_pe (
id INTEGER PRIMARY KEY,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
nom_deal TEXT NOT NULL,
strategie TEXT, -- texte libre : 'Capital développement', 'Capital risque', 'Dette'...
date_souscription TEXT NOT NULL,
duree_mois INTEGER,
montant_investi REAL NOT NULL CHECK(montant_investi > 0),
objectif TEXT, -- texte libre : "x4,5" ou "9 % - 12 %/an"
statut TEXT NOT NULL DEFAULT 'valide'
CHECK(statut IN ('en_attente','valide','cloture','perte_definitive')),
reference TEXT,
source TEXT NOT NULL DEFAULT 'manuel',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_inv ON investissements_pe(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_plat ON investissements_pe(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_ws ON investissements_pe(workspace_id)');
// ── Migration : frais PE — investissement_pe_id (17/09/26) ─────────────────
// frais_operations était exclusivement rattachée à investissements (crowdlending).
// Pour permettre la traçabilité des frais des deals PE (investissements_pe), on ajoute
// une colonne investissement_pe_id nullable, on desserre investissement_id (désormais
// nullable lui aussi), et on impose par CHECK qu'exactement une des deux références
// soit renseignée. Reconstruction de table (SQLite ne permet pas d'altérer une
// contrainte NOT NULL ni un CHECK en place) — même stratégie que les migrations
// précédentes de frais_operations, cf. plus haut dans ce fichier.
{
const fraisOpColsPe = db.prepare('PRAGMA table_info(frais_operations)').all().map(c => c.name);
if (!fraisOpColsPe.includes('investissement_pe_id')) {
const tempName = '__repair_frais_operations_pe';
const fixedDdl = `
CREATE TABLE "${tempName}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE,
investissement_pe_id INTEGER REFERENCES investissements_pe(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
remboursement_id INTEGER REFERENCES remboursements(id) ON DELETE CASCADE,
montant REAL NOT NULL,
mode_reglement TEXT NOT NULL DEFAULT 'source'
CHECK(mode_reglement IN ('source','portefeuille','compte_courant','remboursement')),
compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL,
date_operation TEXT NOT NULL,
origine TEXT NOT NULL DEFAULT 'manuel'
CHECK(origine IN ('souscription','remboursement','manuel')),
categorie TEXT NOT NULL DEFAULT 'autre',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK ((investissement_id IS NOT NULL) + (investissement_pe_id IS NOT NULL) = 1)
)
`;
const colDefs = db.prepare('PRAGMA table_info(frais_operations)').all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='frais_operations' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM frais_operations`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE frais_operations');
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO frais_operations`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv_pe ON frais_operations(investissement_pe_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
}
}
// Historique des valorisations (relevés périodiques du fonds) — une ligne par
// date de valorisation, plutôt qu'un champ unique écrasé à chaque mise à jour,
// pour permettre un graphique d'évolution et un TVPI recalculable à toute date
// passée (décision Olivier 17/09/26).
db.exec(`
CREATE TABLE IF NOT EXISTS investissements_pe_valorisations (
id INTEGER PRIMARY KEY,
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
date_valorisation TEXT NOT NULL,
valorisation_nette REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)');
// Migration : décomposition optionnelle de la valorisation en capital déployé / plus-value
// latente (18/09/26, demande Olivier — construire une carte de répartition façon Fundora sur la
// fiche détail PE). Capital non déployé n'est JAMAIS stocké : il se déduit à l'affichage/au
// calcul (montant_investi du deal − capital_deploye de cette valorisation). Colonnes nullables :
// une valorisation peut rester en saisie simple (un seul montant net), sans détail — c'est
// d'ailleurs le cas de toutes les valorisations existantes avant cette migration.
const invPeValCols = db.prepare('PRAGMA table_info(investissements_pe_valorisations)').all().map(c => c.name);
if (!invPeValCols.includes('capital_deploye')) {
db.exec('ALTER TABLE investissements_pe_valorisations ADD COLUMN capital_deploye REAL');
}
if (!invPeValCols.includes('plus_value_latente')) {
db.exec('ALTER TABLE investissements_pe_valorisations ADD COLUMN plus_value_latente REAL');
}
// Migration : capital investissable (18/09/26, demande Olivier — bug remonté : le "capital non
// déployé" d'une valorisation détaillée était calculé sur montant_investi (le montant souscrit
// BRUT), alors qu'une partie a pu être immédiatement prélevée en frais "à la source" à la
// souscription (cf. syncValorisationInitiale, InvestissementsPe.jsx) — le capital réellement
// disponible à déployer est montant_investi − ces frais, jamais le montant brut. Colonne
// nullable, calculée et fournie par le front à chaque sauvegarde du deal (POST/PUT
// /investissements-pe) à partir des frais "à la source" saisis à l'étape "Frais" de la modale ;
// NULL = deal sans frais à la source connus, auquel cas montant_investi fait référence partout
// où capital_investissable est utilisé (cf. investissementsPe.js).
const invPeCols = db.prepare('PRAGMA table_info(investissements_pe)').all().map(c => c.name);
if (!invPeCols.includes('capital_investissable')) {
db.exec('ALTER TABLE investissements_pe ADD COLUMN capital_investissable REAL');
// Backfill : pour tout deal ayant déjà une "Valorisation initiale" auto-générée (créée par
// syncValorisationInitiale AVANT cette migration, donc déjà nette des frais à la source), son
// montant EST la définition de capital_investissable — évite d'obliger Olivier à rouvrir et
// ré-enregistrer chaque deal existant pour que le calcul se corrige. Les libellés doivent
// rester synchronisés avec AUTO_VAL_NOTE / AUTO_VAL_NOTE_LEGACY (InvestissementsPe.jsx).
db.exec(`
UPDATE investissements_pe
SET capital_investissable = (
SELECT v.valorisation_nette FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = investissements_pe.id
AND v.notes IN ('Valorisation initiale (souscription)', 'Valorisation initiale (frais de souscription)')
ORDER BY v.date_valorisation ASC, v.id ASC LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = investissements_pe.id
AND v.notes IN ('Valorisation initiale (souscription)', 'Valorisation initiale (frais de souscription)')
)
`);
}
// Mode de détention du deal PE (19/09/26, demande Olivier) — prépare le terrain pour un futur
// calcul fiscal PE sans le construire maintenant (cf. project_workspaces_transformation.md) :
// 'direct' (compte-titres classique, flat tax au moment du versement, ex. Fundora) | 'pea_pme'
// (enveloppe PEA/PEA-PME, ex. Baltis) | 'assurance_vie'. Même principe que fiscalite_override
// côté crowdlending (cf. project_fiscalite_override.md), mais en énumération à 3 valeurs plutôt
// qu'un booléen "exonéré ou pas" : PEA-PME et assurance-vie ont des régimes fiscaux différents
// l'un de l'autre, pas juste "exonéré ou pas". Informatif pour l'instant, aucun calcul fiscal
// n'en dépend encore.
if (!invPeCols.includes('mode_detention')) {
db.exec("ALTER TABLE investissements_pe ADD COLUMN mode_detention TEXT NOT NULL DEFAULT 'direct'");
}
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
// de capital, plus-value) perçue sur un deal PE est saisie comme un
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
// Olivier ("comme un remboursement, page déjà construite") — ce lien
// supplémentaire permet de rattacher cette distribution à un deal précis
// (indispensable quand plusieurs deals partagent une même plateforme, ex.
// Fundora) afin de calculer un TVPI par deal plutôt que par plateforme.
// Nullable et sans effet sur les remboursements crowdlending existants.
{
const rembCols2 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols2.includes('investissement_pe_id')) {
db.exec('ALTER TABLE remboursements ADD COLUMN investissement_pe_id INTEGER REFERENCES investissements_pe(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissement_pe_id)');
// Migration : nature d'une distribution PE (18/09/26, demande Olivier — distinguer, au sein des
// distributions reçues sur un deal PE, ce qui restitue le capital déployé ('capital') de ce qui
// est un revenu d'intérêt versé sur le capital NON déployé, placé par le fonds sur un support
// obligataire en attendant d'être appelé ('interet_non_deploye') — reprend la ligne "Obligation"
// des captures Fundora, jusque-là non reconstituable faute de cette distinction. Nullable et
// sans CHECK (validé côté Zod, cf. remboursements.js) : une distribution non classée n'est ni
// l'un ni l'autre, elle reste comptée dans "Distributions" par défaut (cf.
// investissementsPe.js::GET / et GET /:id). Sans effet sur les remboursements crowdlending
// (type 'normal') ni sur les bonus (parrainage/plateforme), seul le type 'interets_plateforme'
// (rebaptisé "Distribution" côté PE) l'utilise.
{
const rembCols3 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols3.includes('distribution_nature')) {
db.exec('ALTER TABLE remboursements ADD COLUMN distribution_nature TEXT');
}
}
// Chantier "Fiscalité PE" (20/09/26, demande Olivier) ───────────────────────────────────────
//
// 1) Troisième nature de distribution : 'plus_value' (plus-value réalisée, distribuée par le
// fonds au fil des cessions de son portefeuille sous-jacent — cf. échange avec Olivier :
// contrairement à un compte-titres classique où l'on "vend" soi-même sa position en un seul
// événement, un fonds PE (FCPR/FPCI) restitue le produit de ses propres cessions par
// distributions successives). Choix : rattacher la plus-value réalisée aux distributions
// existantes plutôt que de construire un événement "cession" séparé (option écartée) — colle
// au fonctionnement réel et réutilise le mécanisme déjà en place (montant saisi dans
// interets_bruts, comme 'interet_non_deploye', cf. RembFormModal.jsx / RemboursementsSimple.jsx).
// Aucune migration de schéma nécessaire : distribution_nature est un TEXT libre sans CHECK
// (cf. commentaire ci-dessus), donc 'plus_value' est déjà une valeur valide en base — seule
// la validation Zod (remboursements.js) et les libellés frontend doivent la reconnaître.
//
// 2) Pertes définitives PE : jusqu'ici investissements_pe.statut acceptait déjà la valeur
// 'perte_definitive' (cf. investissementsPe.js, Zod), mais rien ne permettait d'en tracer le
// montant/la date/le motif pour la fiscalité — contrairement au crowdlending
// (investissement_pertes, case 2TY). Table analogue, même structure, adaptée à
// investissements_pe (pas de notion "ancien_statut" en_retard/procedure côté PE : un deal PE
// passe directement de 'valide'/'cloture' à 'perte_definitive', jamais via un statut
// intermédiaire de recouvrement).
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_pe_pertes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
date_effet TEXT NOT NULL,
montant_perte REAL NOT NULL CHECK(montant_perte >= 0),
ancien_statut TEXT,
motif TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_pe_pertes_inv ON investissement_pe_pertes(investissement_pe_id)');
// ── Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) ──────────
// Catalogue admin de banques/courtiers/assureurs, sur le modèle de plateformes_referentiel
// (cf. project_referentiel_plateformes.md) mais SANS les champs spécifiques aux plateformes
// d'investissement (fiscalité, produit fiscal, frais...) et SANS rattachement workspace : une
// institution financière est transverse à tous les espaces de travail (décision d'Olivier,
// AskUserQuestion 26/09/26). Phase 1 uniquement : catalogue admin (logos/icônes/catégories).
// Le rattachement à comptes.banque (Phase 2) fera l'objet d'un chantier séparé — cf. plan de
// déploiement (doc projet claude/plan_referentiel_institutions_financieres.md).
db.exec(`
CREATE TABLE IF NOT EXISTS institutions_referentiel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL UNIQUE,
url TEXT,
domiciliation TEXT NOT NULL DEFAULT 'FR',
description TEXT,
logo_filename TEXT,
icone_filename 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_instref_nom ON institutions_referentiel(nom)');
// Catégories (axe unique "type d'établissement"), gérées par l'admin — même patron que
// categories_inv/referentiel_categories_inv (cf. plus haut) mais table dédiée : une catégorie
// d'établissement financier (Banque, Assureur...) n'a pas de sens pour une plateforme
// crowdlending/PE, on évite donc de mélanger les deux taxonomies.
db.exec(`
CREATE TABLE IF NOT EXISTS categories_institutions (
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_institutions (
institution_id INTEGER NOT NULL REFERENCES institutions_referentiel(id) ON DELETE CASCADE,
categorie_id INTEGER NOT NULL REFERENCES categories_institutions(id) ON DELETE CASCADE,
PRIMARY KEY (institution_id, categorie_id)
)
`);
// Seed initial des catégories (idempotent — n'insère que si la table est vide ; l'admin reste
// libre de renommer/supprimer ensuite depuis l'écran dédié).
{
const catInstCount = db.prepare('SELECT COUNT(*) AS n FROM categories_institutions').get().n;
if (catInstCount === 0) {
const insCatInst = db.prepare('INSERT OR IGNORE INTO categories_institutions (nom) VALUES (?)');
db.transaction(() => {
for (const nom of ['Banque', 'Néobanque', 'Courtier', 'Assureur', 'Société de gestion', 'Établissement de paiement', 'Autre']) {
insCatInst.run(nom);
}
})();
}
}
// Seed initial des institutions financières (proposition validée par Olivier le 26/09/26,
// via ajustements sur la liste par défaut) — idempotent, n'insère que si la table est vide.
// Chaque institution peut porter plusieurs catégories (ex. Trade Republic : Néobanque + Courtier).
{
const instRefCount = db.prepare('SELECT COUNT(*) AS n FROM institutions_referentiel').get().n;
if (instRefCount === 0) {
const SEED_INSTITUTIONS = [
{ nom: 'BNP Paribas', categories: ['Banque'] },
{ nom: 'Société Générale', categories: ['Banque'] },
{ nom: 'Crédit Agricole', categories: ['Banque'] },
{ nom: 'Banque Populaire', categories: ['Banque'] },
{ nom: "Caisse d'Épargne", categories: ['Banque'] },
{ nom: 'Crédit Mutuel', categories: ['Banque'] },
{ nom: 'CIC', categories: ['Banque'] },
{ nom: 'La Banque Postale', categories: ['Banque'] },
{ nom: 'LCL', categories: ['Banque'] },
{ nom: 'HSBC Continental Europe (CCF)', categories: ['Banque'] },
{ nom: 'Boursorama Banque', categories: ['Néobanque'] },
{ nom: 'Hello bank!', categories: ['Néobanque'] },
{ nom: 'Fortuneo', categories: ['Néobanque'] },
{ nom: 'BforBank', categories: ['Néobanque'] },
{ nom: 'Monabanq', categories: ['Néobanque'] },
{ nom: 'N26', categories: ['Néobanque'] },
{ nom: 'Revolut', categories: ['Néobanque'] },
{ nom: 'Qonto', categories: ['Néobanque'] },
{ nom: 'Trade Republic', categories: ['Néobanque', 'Courtier'] },
{ nom: 'Saxo Banque France', categories: ['Néobanque'] },
{ nom: 'Wise', categories: ['Néobanque'] },
{ nom: 'Bourse Direct', categories: ['Courtier'] },
{ nom: 'DEGIRO', categories: ['Courtier'] },
{ nom: 'Interactive Brokers', categories: ['Courtier'] },
{ nom: 'EasyBourse', categories: ['Courtier'] },
{ nom: 'AXA France', categories: ['Assureur'] },
{ nom: 'Allianz France', categories: ['Assureur'] },
{ nom: 'Generali France', categories: ['Assureur'] },
{ nom: 'CNP Assurances', categories: ['Assureur'] },
{ nom: 'MAIF', categories: ['Assureur'] },
{ nom: 'MACIF', categories: ['Assureur'] },
{ nom: 'Groupama', categories: ['Assureur'] },
{ nom: 'Crédit Agricole Assurances (Predica)', categories: ['Assureur'] },
{ nom: 'BNP Paribas Cardif', categories: ['Assureur'] },
{ nom: 'Société Générale Assurances (Sogecap)', categories: ['Assureur'] },
{ nom: 'Swiss Life France', categories: ['Assureur'] },
{ nom: 'Abeille Assurances (ex-Aviva France)', categories: ['Assureur'] },
{ nom: 'Suravenir (Crédit Mutuel Arkéa)', categories: ['Assureur'] },
{ nom: 'Spirica', categories: ['Assureur'] },
{ nom: 'Amundi', categories: ['Société de gestion'] },
{ nom: 'Natixis Investment Managers', categories: ['Société de gestion'] },
{ nom: 'BNP Paribas Asset Management', categories: ['Société de gestion'] },
{ nom: 'Lazard Frères Gestion', categories: ['Société de gestion'] },
{ nom: 'Carmignac', categories: ['Société de gestion'] },
{ nom: 'Tikehau Capital', categories: ['Société de gestion'] },
{ nom: "La Financière de l'Échiquier", categories: ['Société de gestion'] },
{ nom: 'Eiffel Investment Group', categories: ['Société de gestion'] },
{ nom: 'Lydia (Sumeria)', categories: ['Établissement de paiement'] },
{ nom: 'PayPal', categories: ['Établissement de paiement'] },
{ nom: 'Nickel', categories: ['Établissement de paiement'] },
];
const insInstRef = db.prepare('INSERT OR IGNORE INTO institutions_referentiel (nom) VALUES (?)');
const getInstRefId = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?');
const getCatInstId = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?');
const linkInstCat = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)');
db.transaction(() => {
for (const { nom, categories } of SEED_INSTITUTIONS) {
insInstRef.run(nom);
const inst = getInstRefId.get(nom);
if (!inst) continue;
for (const catNom of categories) {
const cat = getCatInstId.get(catNom);
if (cat) linkInstCat.run(inst.id, cat.id);
}
}
})();
}
}
// ── Migration : institution_id sur comptes (Phase 2, demande Olivier 26/09/26) ────────────
// Rattache un compte au référentiel institutions_referentiel — nullable, non-destructif :
// le champ banque (texte libre) reste en base tel quel pour les comptes non encore réconciliés
// (cf. plan de déploiement, doc projet claude/plan_referentiel_institutions_financieres.md).
{
const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name);
if (!cols.includes('institution_id')) {
db.exec('ALTER TABLE comptes ADD COLUMN institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL');
}
}
// ── 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();
// ── Migration : eligible_compte sur enveloppes_referentiel (Phase 3a, 28/09/26, demande
// Olivier — chantier "rattacher Mes comptes courants au référentiel") ────────────────────
// Distingue les enveloppes qui ont le profil d'un vrai "compte" (banque/courtier/assureur —
// Livret A, PEA, Assurance-vie, CTO, Compte crypto exchange...) de celles qui n'en ont pas
// (immobilier physique, objets de collection, wallets crypto auto-conservés, placements via
// plateforme déjà couverts par le mécanisme plateformes_referentiel...). Sert à filtrer le
// sélecteur "Enveloppe" de Paramètres > Mes comptes (cf. comptes.enveloppe_id ci-dessous) —
// cadrage validé par Olivier via AskUserQuestion (28/09/26) : sous-ensemble "compte financier",
// pas tout le référentiel. Colonne booléenne plutôt que catégorie existante réutilisée : la
// classification ne suit aucune des 15 catégories actuelles (ex. "Actifs numériques" contient
// à la fois Compte crypto exchange, éligible, et les wallets auto-conservés, non éligibles).
{
const cols = db.prepare('PRAGMA table_info(enveloppes_referentiel)').all().map(c => c.name);
if (!cols.includes('eligible_compte')) {
db.exec("ALTER TABLE enveloppes_referentiel ADD COLUMN eligible_compte INTEGER NOT NULL DEFAULT 0");
// Backfill exécuté une seule fois (dans cette même garde de colonne) : classification
// initiale par nom, jamais retouchée ensuite même si l'admin édite ce flag à la main.
const ELIGIBLE_COMPTE_NOMS = [
// Comptes courants
'Compte courant non rémunéré', 'Compte courant rémunéré', 'Compte rémunéré nouvelle génération (fintech)',
// Épargne réglementée
'Livret A', 'LDDS', 'LEP', 'Livret Jeune', 'CEL', 'PEL',
// Épargne bancaire non réglementée
'Livret bancaire non réglementé', 'Compte à terme (CAT)',
// Assurance-vie et assimilés
'Assurance-vie monosupport (fonds euros)', 'Assurance-vie multisupport',
'Contrat de capitalisation', 'Assurance-vie luxembourgeoise',
// Épargne retraite
'PER individuel', 'PER collectif / obligatoire (PERCOL, article 83)',
// Épargne salariale
'PEE',
// Enveloppes titres / marchés financiers
'PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)',
// Actifs numériques — seul le compte sur exchange a une institution (l'exchange
// lui-même) ; les wallets auto-conservés/conservés par un tiers restent hors périmètre.
'Compte crypto (exchange)',
// Épargne solidaire
'Livret solidaire',
];
const setEligible = db.prepare('UPDATE enveloppes_referentiel SET eligible_compte = 1 WHERE nom = ?');
db.transaction(() => { for (const nom of ELIGIBLE_COMPTE_NOMS) setEligible.run(nom); })();
}
}
// ── Migration : enveloppe_id sur comptes (Phase 3a, 28/09/26, demande Olivier) ────────────
// Rattache un compte au référentiel enveloppes_referentiel — nullable, non-destructif, sur le
// même principe que comptes.institution_id (Phase 2, cf. plan de déploiement Institutions
// financières) : le champ `type` existant (compte_courant/pea_pme) reste en base tel quel et
// continue de piloter la logique de remboursement/frais déjà en place (de nombreux écrans
// investissement/remboursement font comptes.find(c => c.type === 'compte_courant') pour
// proposer un compte de règlement par défaut) — enveloppe_id est un enrichissement PUREMENT
// descriptif, ajouté à côté du champ type, jamais en remplacement, pour ne rien casser de cette
// logique existante. Restreint aux enveloppes "compte financier" (eligible_compte = 1) côté
// sélecteur frontend uniquement — pas de contrainte CHECK en base, pour rester simple.
{
const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name);
if (!cols.includes('enveloppe_id')) {
db.exec('ALTER TABLE comptes ADD COLUMN enveloppe_id INTEGER REFERENCES enveloppes_referentiel(id) ON DELETE SET NULL');
}
}
// ── Extension du 28/09/26 (Phase 3a) — catégorie "Gestion de trésorerie" + Spiko / Regular
// Finance ──────────────────────────────────────────────────────────────────────────────────
// Identifiés pendant le chantier "Référentiel Enveloppes & Supports d'épargne" (cf. "Extension
// du 29/09/26 (suite) — support Fonds monétaires" dans le plan de déploiement dédié) : 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 tokenisé sur blockchain chez Spiko) — elles ont
// vocation à rejoindre institutions_referentiel, pas enveloppes_referentiel. Toujours exécuté,
// INSERT OR IGNORE sur des clés UNIQUE(nom) — s'ajoute sans jamais dupliquer ni écraser le
// référentiel déjà seedé (Phase 1), même principe que les extensions "EXTRA" du chantier
// enveloppes.
{
db.prepare('INSERT OR IGNORE INTO categories_institutions (nom) VALUES (?)').run('Gestion de trésorerie');
const insInst = db.prepare('INSERT OR IGNORE INTO institutions_referentiel (nom) VALUES (?)');
const getInst = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?');
const getCat = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?');
const linkCat = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)');
db.transaction(() => {
for (const nom of ['Spiko', 'Regular Finance']) {
insInst.run(nom);
const inst = getInst.get(nom);
const cat = getCat.get('Gestion de trésorerie');
if (inst && cat) linkCat.run(inst.id, cat.id);
}
})();
}
// ── Chantier "Passif — Emprunts" (Phase 3c, 28/09/26, demande Olivier) ───────────────────────
// Objet séparé de enveloppes_referentiel : un emprunt est structurellement différent d'un actif
// (le capital restant dû DIMINUE avec le temps, porte un taux et un échéancier fixés à la
// souscription) — cf. "Réflexion prospective" du plan de déploiement Enveloppes & Supports (doc
// projet), point 3 ("un passif, pas un actif"). Table dédiée plutôt qu'un champ sur comptes,
// pour la même raison qu'un compte n'est jamais une "enveloppe" : structure et cycle de vie
// différents (un compte se crédite/débite librement, un emprunt s'amortit). Rattachement
// institution optionnel (établissement prêteur) via institutions_referentiel, sur le même
// principe que comptes.institution_id — nullable, non contraignant.
db.exec(`
CREATE TABLE IF NOT EXISTS emprunts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL,
nom TEXT NOT NULL,
montant_emprunte REAL NOT NULL,
capital_restant_du REAL NOT NULL,
taux_nominal REAL,
date_debut TEXT,
duree_mois INTEGER,
mensualite REAL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_emprunts_user ON emprunts(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_emprunts_inv ON emprunts(investisseur_id)');
export default db;