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 }); 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 ── { const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); 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 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 // 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 des date_cible déjà calculées avec l'ancienne formule (+duree_mois au lieu de +duree_mois-1) 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 needsFix = db.prepare(` SELECT COUNT(*) AS n FROM investissements WHERE type_remb = 'differe' AND date_cible IS NOT NULL AND (date_premiere_echeance IS NULL OR date_premiere_echeance != date_cible) `).get().n; if (needsFix > 0) { db.exec(` UPDATE investissements SET date_premiere_echeance = date_cible WHERE type_remb = 'differe' AND date_cible IS NOT NULL `); // 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 : 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, ` `, '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 : 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'); } // ── 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'); } } export default db;