diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 116fc9a..4819f90 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -3657,4 +3657,16 @@ db.exec(` } } + +// ── Migration : institution_id sur comptes (Phase 2, demande Olivier 26/09/26) ──────────── +// Rattache un compte au référentiel institutions_referentiel — nullable, non-destructif : +// le champ banque (texte libre) reste en base tel quel pour les comptes non encore réconciliés +// (cf. plan de déploiement, doc projet claude/plan_referentiel_institutions_financieres.md). +{ + const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name); + if (!cols.includes('institution_id')) { + db.exec('ALTER TABLE comptes ADD COLUMN institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL'); + } +} + export default db; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index f8df0bd..009bb78 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -832,4 +832,90 @@ router.post('/exports/:filename/restore', async (req, res, next) => { } }); + +/* ── Comptes orphelins (banque texte libre non reliée au référentiel institutions) ─────── + Phase 2 du chantier "Référentiel Institutions financières" (cf. plan de déploiement, doc + projet claude/plan_referentiel_institutions_financieres.md). Contrairement aux plateformes + orphelines (une ligne par plateforme), ici on regroupe par valeur `banque` distincte car + plusieurs comptes (potentiellement de plusieurs utilisateurs) partagent souvent le même + texte libre — Lier/Importer agit donc sur tous les comptes portant cette valeur d'un coup. */ + +/** Liste les valeurs `banque` distinctes non reliées à institution_id, avec suggestion */ +router.get('/comptes-orphelins', (_req, res) => { + const rows = db.prepare(` + SELECT c.banque, u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name, COUNT(*) AS nb + FROM comptes c + JOIN users u ON u.id = c.user_id + WHERE c.institution_id IS NULL AND c.banque IS NOT NULL AND TRIM(c.banque) != '' + GROUP BY c.banque, u.id + ORDER BY c.banque, u.email + `).all(); + + const byBanque = {}; + for (const r of rows) { + if (!byBanque[r.banque]) byBanque[r.banque] = { banque: r.banque, total: 0, users: [] }; + byBanque[r.banque].total += r.nb; + byBanque[r.banque].users.push({ id: r.user_id, email: r.user_email, display_name: r.user_display_name, nb: r.nb }); + } + + const refs = db.prepare('SELECT id, nom FROM institutions_referentiel ORDER BY nom').all(); + + const enriched = Object.values(byBanque).map(g => { + let bestRef = null, bestScore = 0; + for (const ref of refs) { + const score = similarity(g.banque, ref.nom); + if (score > bestScore) { bestScore = score; bestRef = ref; } + } + return { + ...g, + suggestion: bestScore >= SIMILARITY_THRESHOLD + ? { institution_id: bestRef.id, institution_nom: bestRef.nom, score: Math.round(bestScore * 100) } + : null, + }; + }).sort((a, b) => a.banque.localeCompare(b.banque, 'fr')); + + res.json(enriched); +}); + +/** Lie tous les comptes portant cette valeur `banque` à une institution existante du référentiel */ +router.post('/comptes-orphelins/lier', (req, res, next) => { + try { + const { banque, institution_id } = z.object({ + banque: z.string().min(1), + institution_id: z.number().int().positive(), + }).parse(req.body); + + const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institution_id); + if (!ref) throw new HttpError(404, 'Institution introuvable'); + + const r = db.prepare( + 'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL' + ).run(institution_id, banque); + + res.json({ linked: true, nb_comptes: r.changes, institution: ref }); + } catch (e) { next(e); } +}); + +/** Crée une nouvelle institution dans le référentiel à partir de la valeur `banque`, et lie + tous les comptes correspondants (ou lie simplement si une institution de ce nom existe déjà) */ +router.post('/comptes-orphelins/importer', (req, res, next) => { + try { + const { banque } = z.object({ banque: z.string().min(1) }).parse(req.body); + + let ref = db.prepare('SELECT * FROM institutions_referentiel WHERE nom = ?').get(banque); + let created = false; + if (!ref) { + const r = db.prepare('INSERT INTO institutions_referentiel (nom) VALUES (?)').run(banque); + ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(r.lastInsertRowid); + created = true; + } + + const upd = db.prepare( + 'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL' + ).run(ref.id, banque); + + res.status(created ? 201 : 200).json({ linked: true, created, nb_comptes: upd.changes, institution: ref }); + } catch (e) { next(e); } +}); + export default router; diff --git a/backend/src/routes/comptes.js b/backend/src/routes/comptes.js index 7e04148..a0614a8 100644 --- a/backend/src/routes/comptes.js +++ b/backend/src/routes/comptes.js @@ -10,16 +10,20 @@ const Schema = z.object({ type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'), investisseur_id: z.number().int().positive().nullable().optional(), banque: z.string().nullable().optional(), + institution_id: z.number().int().positive().nullable().optional(), exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'), }); router.get('/', (req, res) => { const rows = db.prepare(` - SELECT c.id, c.nom, c.type, c.banque, c.exoneration_fiscale, c.investisseur_id, c.created_at, + SELECT c.id, c.nom, c.type, c.banque, c.institution_id, c.exoneration_fiscale, c.investisseur_id, c.created_at, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, - inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal + inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal, + ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation, + ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename FROM comptes c LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id + LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id WHERE c.user_id = ? ORDER BY c.nom `).all(req.user.id); @@ -30,8 +34,8 @@ router.post('/', (req, res, next) => { try { const body = Schema.parse(req.body); const r = db.prepare( - 'INSERT INTO comptes (user_id, nom, type, banque, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?)' - ).run(req.user.id, body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune'); + 'INSERT INTO comptes (user_id, nom, type, banque, institution_id, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?,?)' + ).run(req.user.id, body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune'); res.status(201).json({ id: r.lastInsertRowid, ...body }); } catch (e) { next(e); } }); @@ -40,8 +44,8 @@ router.put('/:id', (req, res, next) => { try { const body = Schema.parse(req.body); const r = db.prepare( - `UPDATE comptes SET nom=?, type=?, banque=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?` - ).run(body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id); + `UPDATE comptes SET nom=?, type=?, banque=?, institution_id=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?` + ).run(body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id); if (r.changes === 0) throw new HttpError(404, 'Not found'); res.json({ id: Number(req.params.id), ...body }); } catch (e) { next(e); } diff --git a/backend/src/routes/institutionsReferentielPublic.js b/backend/src/routes/institutionsReferentielPublic.js new file mode 100644 index 0000000..e0ded8f --- /dev/null +++ b/backend/src/routes/institutionsReferentielPublic.js @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +/** + * routes/institutionsReferentielPublic.js — lecture seule du référentiel "Institutions + * financières" pour tous les utilisateurs authentifiés (pas requireAdmin), sur le modèle de + * referentielPublic.js. Contrairement aux plateformes (où chaque utilisateur crée sa propre + * entrée, réconciliée a posteriori par l'admin via OrphelinesSection), le référentiel + * institutions est un catalogue transverse : on expose ici une LISTE complète, utilisée par le + * sélecteur "Banque" de Paramètres > Mes comptes (Phase 2, cf. plan de déploiement). + */ + +const router = Router(); +// Monté sous requireAuth (sans requireAdmin) — lecture seule. + +function attachCategories(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const links = db.prepare(` + SELECT rc.institution_id, c.id, c.nom + FROM referentiel_categories_institutions rc + JOIN categories_institutions c ON c.id = rc.categorie_id + WHERE rc.institution_id IN (${ids.map(() => '?').join(',')}) + ORDER BY c.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.institution_id]) map[l.institution_id] = []; + map[l.institution_id].push({ id: l.id, nom: l.nom }); + } + return rows.map(r => ({ ...r, categories: map[r.id] || [] })); +} + +/* GET /api/institutions-referentiel-public — liste complète (lecture seule) */ +router.get('/', (_req, res) => { + const rows = db.prepare(` + SELECT id, nom, domiciliation, logo_filename, icone_filename + FROM institutions_referentiel + ORDER BY nom + `).all(); + res.json(attachCategories(rows)); +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index 97db360..7efd05c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -51,6 +51,7 @@ import tauxCreditImpotRouter from './routes/tauxCreditImpot.js'; import referentielRouter from './routes/referentiel.js'; import referentielPublicRouter from './routes/referentielPublic.js'; import institutionsReferentielRouter from './routes/institutionsReferentiel.js'; +import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js'; import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js'; import refCategoriesRouter from './routes/ref-categories.js'; import refSecteursRouter from './routes/ref-secteurs.js'; @@ -173,6 +174,7 @@ app.use('/api/referentiel-public', requireAuth, referentielPublicRouter); // Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) — Phase 1 : // catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js. app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter); +app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter); app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter); app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter); app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); diff --git a/design/icones institutions/BNP.png b/design/icones institutions/BNP.png new file mode 100644 index 0000000..6c965b0 Binary files /dev/null and b/design/icones institutions/BNP.png differ diff --git a/design/icones institutions/BNP.webp b/design/icones institutions/BNP.webp new file mode 100644 index 0000000..647c782 Binary files /dev/null and b/design/icones institutions/BNP.webp differ diff --git a/design/icones institutions/Banque Populaire.webp b/design/icones institutions/Banque Populaire.webp new file mode 100644 index 0000000..fc8ec5e Binary files /dev/null and b/design/icones institutions/Banque Populaire.webp differ diff --git a/design/icones institutions/Caisse d'épargne.png b/design/icones institutions/Caisse d'épargne.png new file mode 100644 index 0000000..d7a8064 Binary files /dev/null and b/design/icones institutions/Caisse d'épargne.png differ diff --git a/design/icones institutions/Fortuneo.jpg b/design/icones institutions/Fortuneo.jpg new file mode 100644 index 0000000..b8b5396 Binary files /dev/null and b/design/icones institutions/Fortuneo.jpg differ diff --git a/design/icones institutions/Société général.webp b/design/icones institutions/Société général.webp new file mode 100644 index 0000000..f398926 Binary files /dev/null and b/design/icones institutions/Société général.webp differ diff --git a/design/logos institution/Bforbank - Logo.webp b/design/logos institution/Bforbank - Logo.webp new file mode 100644 index 0000000..90b9a5b Binary files /dev/null and b/design/logos institution/Bforbank - Logo.webp differ diff --git a/design/logos institution/BoursoBank - Logo.webp b/design/logos institution/BoursoBank - Logo.webp new file mode 100644 index 0000000..34227a7 Binary files /dev/null and b/design/logos institution/BoursoBank - Logo.webp differ diff --git a/design/logos institution/Hello Bank - Logo.webp b/design/logos institution/Hello Bank - Logo.webp new file mode 100644 index 0000000..21c5714 Binary files /dev/null and b/design/logos institution/Hello Bank - Logo.webp differ diff --git a/design/logos institution/Monabanq - logo.webp b/design/logos institution/Monabanq - logo.webp new file mode 100644 index 0000000..ae9030f Binary files /dev/null and b/design/logos institution/Monabanq - logo.webp differ diff --git a/design/logos institution/Revolut - logo.webp b/design/logos institution/Revolut - logo.webp new file mode 100644 index 0000000..d7db4df Binary files /dev/null and b/design/logos institution/Revolut - logo.webp differ diff --git a/frontend/src/pages/AdminInstitutions.jsx b/frontend/src/pages/AdminInstitutions.jsx index d8bbbfd..855adcd 100644 --- a/frontend/src/pages/AdminInstitutions.jsx +++ b/frontend/src/pages/AdminInstitutions.jsx @@ -685,10 +685,186 @@ function CategoriesInstitutionsSection() { ); } +function IconLink() { + return ; +} + +/* ── Comptes à réconcilier ───────────────────────────────────────────── + Phase 2 (26/09/26, demande Olivier) : les comptes créés avant l'ajout du référentiel (ou + dont l'utilisateur a choisi "Autre (texte libre)") portent une valeur `banque` en texte + libre non reliée à `institution_id`. Cet écran liste ces valeurs, groupées par texte + (potentiellement partagé par plusieurs comptes/utilisateurs), avec une suggestion de + rapprochement par similarité de nom — sur le modèle d'OrphelinesSection (AdminPlateformes.jsx) + mais opérant sur un groupe de comptes à la fois plutôt que sur une seule plateforme. */ +function ComptesOrphelinsSection() { + const [rows, setRows] = useState([]); + const [institutions, setInstitutions] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [acting, setActing] = useState(null); + const [confirm, setConfirm] = useState(null); + const [result, setResult] = useState(null); + const [selected, setSelected] = useState({}); + + const load = useCallback(async () => { + try { + setLoading(true); + const [orphs, insts] = await Promise.all([ + api.get('/admin/comptes-orphelins'), + api.get('/institutions-referentiel'), + ]); + setRows(orphs); + setInstitutions(insts); + const initSel = {}; + for (const r of orphs) if (r.suggestion) initSel[r.banque] = String(r.suggestion.institution_id); + setSelected(initSel); + } catch (e) { setErr(e.message); } + finally { setLoading(false); } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleLier = (row) => { + const instId = Number(selected[row.banque]); + if (!instId) return; + const inst = institutions.find(i => i.id === instId); + setConfirm({ + title: 'Lier au référentiel', + message: `Lier ${row.total > 1 ? `les ${row.total} comptes portant` : 'le compte portant'} "${row.banque}" à "${inst?.nom}" ? Le texte libre existant n'est pas modifié — seul le rattachement au référentiel est ajouté.`, + confirmLabel: 'Lier', + onConfirm: async () => { + setConfirm(null); setActing(row.banque); + try { + const r = await api.post('/admin/comptes-orphelins/lier', { banque: row.banque, institution_id: instId }); + setResult({ ok: true, msg: `${r.nb_comptes} compte(s) lié(s) à "${r.institution.nom}".` }); + load(); + } catch (e) { setResult({ ok: false, msg: e.message }); } + finally { setActing(null); } + }, + }); + }; + + const handleImport = (row) => { + setConfirm({ + title: 'Importer dans le référentiel', + message: `Créer une nouvelle entrée "${row.banque}" dans le référentiel des institutions et lier automatiquement ${row.total > 1 ? `les ${row.total} comptes correspondants` : 'le compte correspondant'} ?`, + confirmLabel: 'Importer', + onConfirm: async () => { + setConfirm(null); setActing(row.banque); + try { + const r = await api.post('/admin/comptes-orphelins/importer', { banque: row.banque }); + setResult({ + ok: true, + msg: r.created + ? `"${r.institution.nom}" créée dans le référentiel — ${r.nb_comptes} compte(s) lié(s).` + : `"${r.institution.nom}" existait déjà — ${r.nb_comptes} compte(s) lié(s).`, + }); + load(); + } catch (e) { setResult({ ok: false, msg: e.message }); } + finally { setActing(null); } + }, + }); + }; + + const totalSuggestions = rows.filter(r => r.suggestion).length; + + return ( +
+ Valeurs "Banque" saisies en texte libre (Paramètres > Mes comptes, avant ce référentiel, ou via "Autre") et pas encore reliées à une institution. + {totalSuggestions > 0 && ⚡ {totalSuggestions} correspondance(s) suggérée(s) avec le référentiel existant.} +
+Chargement…
+ : rows.length === 0 + ?| Valeur "Banque" | +Comptes concernés | +Lier à… | +Actions | +
|---|---|---|---|
|
+ {row.banque}
+ {row.suggestion && (
+
+
+ ≈ {row.suggestion.institution_nom} ({row.suggestion.score}%)
+
+
+ )}
+ |
+ + {row.total} compte{row.total > 1 ? 's' : ''} — {row.users.map(u => u.display_name || u.email).join(', ')} + | ++ + | +
+
+
+
+
+ |
+