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