2305 lines
119 KiB
JavaScript
2305 lines
119 KiB
JavaScript
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';
|
||
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 : 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 : 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 par investisseur. `type` permet de réutiliser la table
|
||
// pour d'autres natures d'objectifs plus tard (ex: 'rendement_annuel') —
|
||
// pour l'instant seul 'versement_annuel' (objectif de dépôts nets) est utilisé.
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS objectifs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(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')),
|
||
UNIQUE(investisseur_id, type, annee)
|
||
)
|
||
`);
|
||
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)');
|
||
|
||
// ── 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');
|
||
}
|
||
}
|
||
|
||
export default db;
|