Implementation de la notion d'espace d'investissement

This commit is contained in:
ocroguennec committed 2026-09-17 18:25:42 +02:00
1 parent 6b6cad7cd6
commit 4b7df66862
54 files changed
+6782 -288

No files matched your search

+259
View File
@@ -3069,6 +3069,22 @@ db.exec(`
`);
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, actif_global, ordre)
@@ -3103,4 +3119,247 @@ export function grantDefaultWorkspace(userId) {
}
}
// ── 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 slug = '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 slug = '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 slug = '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 slug = '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)');
// 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)');
export default db;