Files
crowdlending-app/backend/src/routes/plateformes.js
T

822 lines
37 KiB
JavaScript

import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { createZip, readZip } from '../utils/zip.js';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const logosDir = path.resolve(__dirname, '../../../data/logos');
fs.mkdirSync(logosDir, { recursive: true });
/** Sanitise un nom de plateforme pour en faire un nom de fichier safe */
function sanitizeNom(nom) {
return (nom || 'plateforme')
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'plateforme';
}
const ALLOWED_MIMES = { 'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg' };
const logoStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, logosDir),
filename: (req, file, cb) => {
const plat = db.prepare('SELECT nom FROM plateformes WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
const ext = ALLOWED_MIMES[file.mimetype] || 'png';
cb(null, `logo_${sanitizeNom(plat?.nom || String(req.params.id))}_${Date.now()}.${ext}`);
},
});
const upload = multer({
storage: logoStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (ALLOWED_MIMES[file.mimetype]) cb(null, true);
else cb(new HttpError(400, 'Format non supporté (SVG, PNG, JPEG uniquement)'));
},
});
const router = Router();
const Schema = z.object({
nom: z.string().min(1),
url: z.string().url().optional().or(z.literal('')),
notes: z.string().optional(),
categories: z.array(z.number().int().positive()).optional().default([]),
domiciliation: z.string().min(1).max(100).default('france'),
fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'),
taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(),
type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'),
methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).default('portefeuille'),
investisseur_id: z.number().int().positive().nullable().optional(),
date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(),
freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(),
referentiel_id: z.number().int().positive().nullable().optional(),
});
// Champs hérités du référentiel (comparés pour calculer overridden_fields)
const HERITABLE_FIELDS = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'icone_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut'];
/**
* Calcule overridden_fields : champs dont la valeur diffère du référentiel.
* Retourne le tableau mis à jour des champs overridés.
*/
function computeOverridden(ref, newValues, currentOverridden = []) {
if (!ref) return currentOverridden;
const overridden = new Set(currentOverridden);
for (const field of HERITABLE_FIELDS) {
const refVal = ref[field] ?? null;
const newVal = newValues[field] ?? null;
// Comparer en string pour éviter les faux positifs number vs null
if (String(refVal) !== String(newVal)) {
overridden.add(field);
} else {
overridden.delete(field);
}
}
return [...overridden];
}
function attachCategories(userId, rows) {
if (rows.length === 0) return rows;
const platIds = rows.map(r => r.id);
const cats = db.prepare(`
SELECT pc.plateforme_id, c.id, c.nom
FROM plateforme_categories pc
JOIN categories_plateforme c ON c.id = pc.categorie_id
WHERE c.user_id = ?
AND pc.plateforme_id IN (${platIds.map(() => '?').join(',')})
ORDER BY c.nom
`).all(userId, ...platIds);
const map = {};
for (const c of cats) {
if (!map[c.plateforme_id]) map[c.plateforme_id] = [];
map[c.plateforme_id].push({ id: c.id, nom: c.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
function attachCategoriesInv(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
// Tags propres à chaque plateforme
const links = db.prepare(`
SELECT pc.plateforme_id, c.id AS categorie_id, c.nom
FROM plateforme_categories_inv pc
JOIN categories_inv c ON c.id = pc.categorie_id
WHERE pc.plateforme_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.plateforme_id]) map[l.plateforme_id] = [];
map[l.plateforme_id].push({ id: l.categorie_id, nom: l.nom, is_inherited: false });
}
// Tags du référentiel pour les plateformes liées (héritage par fusion)
const refIds = [...new Set(rows.filter(r => r.referentiel_id).map(r => r.referentiel_id))];
const refCatMap = {};
if (refIds.length > 0) {
const refLinks = db.prepare(`
SELECT rc.referentiel_id, c.id AS categorie_id, c.nom
FROM referentiel_categories_inv rc
JOIN categories_inv c ON c.id = rc.categorie_id
WHERE rc.referentiel_id IN (${refIds.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...refIds);
for (const l of refLinks) {
if (!refCatMap[l.referentiel_id]) refCatMap[l.referentiel_id] = [];
refCatMap[l.referentiel_id].push({ id: l.categorie_id, nom: l.nom });
}
}
return rows.map(r => {
const ownTags = map[r.id] || [];
if (!r.referentiel_id) return { ...r, categories_inv: ownTags };
const excludedIds = new Set(JSON.parse(r.excluded_categories_inv_ids || '[]'));
const refTags = (refCatMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id));
const refIdSet = new Set(refTags.map(t => t.id));
const ownIds = new Set(ownTags.map(t => t.id));
for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id);
const merged = [...ownTags];
for (const rt of refTags) {
if (!ownIds.has(rt.id)) merged.push({ ...rt, is_inherited: true });
}
merged.sort((a, b) => a.nom.localeCompare(b.nom));
return { ...r, categories_inv: merged };
});
}
function attachSecteursInv(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT ps.plateforme_id, s.id AS secteur_id, s.nom
FROM plateforme_secteurs_inv ps
JOIN secteurs_inv s ON s.id = ps.secteur_id
WHERE ps.plateforme_id IN (${ids.map(() => '?').join(',')})
ORDER BY s.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.plateforme_id]) map[l.plateforme_id] = [];
map[l.plateforme_id].push({ id: l.secteur_id, nom: l.nom, is_inherited: false });
}
const refIds = [...new Set(rows.filter(r => r.referentiel_id).map(r => r.referentiel_id))];
const refSectMap = {};
if (refIds.length > 0) {
const refLinks = db.prepare(`
SELECT rs.referentiel_id, s.id AS secteur_id, s.nom
FROM referentiel_secteurs_inv rs
JOIN secteurs_inv s ON s.id = rs.secteur_id
WHERE rs.referentiel_id IN (${refIds.map(() => '?').join(',')})
ORDER BY s.nom
`).all(...refIds);
for (const l of refLinks) {
if (!refSectMap[l.referentiel_id]) refSectMap[l.referentiel_id] = [];
refSectMap[l.referentiel_id].push({ id: l.secteur_id, nom: l.nom });
}
}
return rows.map(r => {
const ownTags = map[r.id] || [];
if (!r.referentiel_id) return { ...r, secteurs_inv: ownTags };
const excludedIds = new Set(JSON.parse(r.excluded_secteurs_inv_ids || '[]'));
const refTags = (refSectMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id));
const refIdSet = new Set(refTags.map(t => t.id));
const ownIds = new Set(ownTags.map(t => t.id));
for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id);
const merged = [...ownTags];
for (const rt of refTags) {
if (!ownIds.has(rt.id)) merged.push({ ...rt, is_inherited: true });
}
merged.sort((a, b) => a.nom.localeCompare(b.nom));
return { ...r, secteurs_inv: merged };
});
}
function syncCategories(platId, catIds) {
db.prepare('DELETE FROM plateforme_categories WHERE plateforme_id=?').run(platId);
if (catIds.length === 0) return;
const ins = db.prepare(
'INSERT OR IGNORE INTO plateforme_categories (plateforme_id, categorie_id) VALUES (?,?)'
);
db.transaction((pid, ids) => {
for (const cid of ids) ins.run(pid, cid);
})(platId, catIds);
}
// ── Lecture référentiel (tous users authentifiés) ─────────────────────────
router.get('/referentiel-list', (_req, res) => {
const rows = db.prepare(`
SELECT pr.id, pr.nom, pr.url, pr.domiciliation, pr.fiscalite,
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename,
pr.methode_remboursement, pr.type_pret_defaut, pr.freq_interets_defaut
FROM plateformes_referentiel pr
ORDER BY pr.nom
`).all();
res.json(rows);
});
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT p.id, p.nom, p.url, p.notes, p.domiciliation, p.fiscalite, p.taux_fiscalite_locale,
p.type_produit_fiscal,
p.methode_remboursement, p.investisseur_id, p.date_ouverture, p.logo_filename, p.icone_filename, p.created_at,
p.type_pret_defaut, p.freq_interets_defaut,
p.referentiel_id, p.overridden_fields,
p.excluded_categories_inv_ids, p.excluded_secteurs_inv_ids,
pr.nom AS referentiel_nom, pr.description AS referentiel_description,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
(SELECT COUNT(*) FROM investissements i
JOIN investisseurs inv2 ON inv2.id = i.investisseur_id
WHERE i.plateforme_id = p.id AND inv2.user_id = p.user_id) AS nb_investissements
FROM plateformes p
LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id
LEFT JOIN plateformes_referentiel pr ON pr.id = p.referentiel_id
WHERE p.user_id = ?
ORDER BY p.nom
`).all(req.user.id);
const enriched = rows.map(r => ({
...r,
overridden_fields: JSON.parse(r.overridden_fields || '[]'),
}));
const withCats = attachCategories(req.user.id, enriched);
const withCatsInv = attachCategoriesInv(withCats);
const withAll = attachSecteursInv(withCatsInv).map(r => {
const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r;
return rest;
});
res.json(withAll);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
let fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite;
let taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null;
let typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT';
let referentielId = body.referentiel_id ?? null;
let logoFilename = null;
let iconeFilename = null;
// Si un référentiel est sélectionné, hériter logo et icone
if (referentielId) {
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentielId);
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
logoFilename = ref.logo_filename ?? null;
iconeFilename = ref.icone_filename ?? null;
}
const r = db.prepare(`
INSERT INTO plateformes
(user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal,
methode_remboursement, investisseur_id, date_ouverture,
type_pret_defaut, freq_interets_defaut,
logo_filename, icone_filename,
referentiel_id, overridden_fields)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
req.user.id, body.nom, body.url || null, body.notes || null,
body.domiciliation, fiscalite, taux, typeProduitFiscal,
body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null,
body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null,
logoFilename, iconeFilename,
referentielId,
'[]'
);
const id = r.lastInsertRowid;
syncCategories(id, body.categories);
res.status(201).json({
id, ...body,
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
referentiel_id: referentielId, overridden_fields: [],
});
} catch (e) { next(e); }
});
// ── Multer memStorage for ZIP uploads ─────────────────────────────────────
const zipUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) cb(null, true);
else cb(new HttpError(400, 'Fichier ZIP attendu'));
},
});
// ── GET /api/plateformes/export — exporte toutes les plateformes de l'user ─
router.get('/export', (req, res, next) => {
try {
let rows = db.prepare(`
SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email
FROM plateformes p
LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id
WHERE p.user_id = ?
ORDER BY p.nom
`).all(req.user.id);
rows = attachCategoriesInv(rows);
rows = attachSecteursInv(rows);
// Attach legacy categories
const platIds = rows.map(r => r.id);
let catsLegacy = [];
if (platIds.length > 0) {
catsLegacy = db.prepare(`
SELECT pc.plateforme_id, c.nom
FROM plateforme_categories pc JOIN categories_plateforme c ON c.id = pc.categorie_id
WHERE c.user_id = ? AND pc.plateforme_id IN (${platIds.map(() => '?').join(',')})
`).all(req.user.id, ...platIds);
}
const legacyMap = {};
for (const c of catsLegacy) {
if (!legacyMap[c.plateforme_id]) legacyMap[c.plateforme_id] = [];
legacyMap[c.plateforme_id].push(c.nom);
}
const entries = [];
entries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.1', app: 'crowdlending', exported_at: new Date().toISOString(), count: rows.length, type: 'plateformes', includes_investisseurs: true }, null, 2) });
const dataRows = rows.map(r => ({
nom: r.nom,
url: r.url,
notes: r.notes,
domiciliation: r.domiciliation,
fiscalite: r.fiscalite,
taux_fiscalite_locale: r.taux_fiscalite_locale,
type_produit_fiscal: r.type_produit_fiscal,
methode_remboursement: r.methode_remboursement,
date_ouverture: r.date_ouverture,
type_pret_defaut: r.type_pret_defaut,
freq_interets_defaut: r.freq_interets_defaut,
logo_filename: r.logo_filename,
icone_filename: r.icone_filename,
investisseur_nom: r.investisseur_nom,
investisseur_prenom: r.investisseur_prenom,
investisseur_email: r.investisseur_email,
categories: legacyMap[r.id] || [],
categories_inv: (r.categories_inv || []).map(c => c.nom),
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
}));
entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) });
// Export des investisseurs (détenteurs) liés aux plateformes
const invIds = [...new Set(rows.map(r => r.investisseur_id).filter(Boolean))];
const invRows = invIds.length > 0
? db.prepare(`SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id IN (${invIds.map(() => '?').join(',')}) AND user_id = ?`).all(...invIds, req.user.id)
: [];
if (invRows.length > 0) {
entries.push({ name: 'investisseurs.json', data: JSON.stringify(invRows, null, 2) });
}
for (const r of rows) {
for (const fname of [r.logo_filename, r.icone_filename]) {
if (!fname) continue;
const fpath = path.join(logosDir, fname);
if (fs.existsSync(fpath)) entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) });
}
}
const zipBuf = createZip(entries);
const slug = 'plateformes-' + new Date().toISOString().slice(0, 10);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`);
res.send(zipBuf);
} catch (e) { next(e); }
});
// ── GET /api/plateformes/:id/export — exporte une plateforme ──────────────
router.get('/:id/export', (req, res, next) => {
try {
const p = db.prepare(`
SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email
FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id
WHERE p.id = ? AND p.user_id = ?
`).get(req.params.id, req.user.id);
if (!p) throw new HttpError(404, 'Plateforme introuvable');
const [withCatsInv] = attachCategoriesInv([p]);
const [withAll] = attachSecteursInv([withCatsInv]);
const catsLegacy = db.prepare(`
SELECT c.nom FROM plateforme_categories pc
JOIN categories_plateforme c ON c.id = pc.categorie_id
WHERE pc.plateforme_id = ? AND c.user_id = ?
`).all(p.id, req.user.id).map(c => c.nom);
const entries = [];
entries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: 1, type: 'plateformes' }, null, 2) });
const dataRow = {
nom: withAll.nom, url: withAll.url, notes: withAll.notes,
domiciliation: withAll.domiciliation, fiscalite: withAll.fiscalite,
taux_fiscalite_locale: withAll.taux_fiscalite_locale,
type_produit_fiscal: withAll.type_produit_fiscal,
methode_remboursement: withAll.methode_remboursement,
date_ouverture: withAll.date_ouverture,
type_pret_defaut: withAll.type_pret_defaut, freq_interets_defaut: withAll.freq_interets_defaut,
logo_filename: withAll.logo_filename, icone_filename: withAll.icone_filename,
investisseur_nom: withAll.investisseur_nom, investisseur_prenom: withAll.investisseur_prenom, investisseur_email: withAll.investisseur_email,
categories: catsLegacy,
categories_inv: (withAll.categories_inv || []).map(c => c.nom),
secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom),
};
entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) });
// Export du détenteur lié à la plateforme
if (withAll.investisseur_id) {
const inv = db.prepare('SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?').get(withAll.investisseur_id, req.user.id);
if (inv) entries.push({ name: 'investisseurs.json', data: JSON.stringify([inv], null, 2) });
}
for (const fname of [withAll.logo_filename, withAll.icone_filename]) {
if (!fname) continue;
const fpath = path.join(logosDir, fname);
if (fs.existsSync(fpath)) entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) });
}
const zipBuf = createZip(entries);
const slug = withAll.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + new Date().toISOString().slice(0, 10);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`);
res.send(zipBuf);
} catch (e) { next(e); }
});
// ── POST /api/plateformes/import-zip — importe un ZIP de plateformes ──────
router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
try {
if (!req.file) throw new HttpError(400, 'Fichier ZIP manquant');
const zipEntries = readZip(req.file.buffer);
const dataEntry = zipEntries.find(e => e.name === 'data.json');
if (!dataEntry) throw new HttpError(400, 'ZIP invalide : data.json manquant');
const platforms = JSON.parse(dataEntry.data.toString('utf8'));
if (!Array.isArray(platforms)) throw new HttpError(400, 'data.json doit être un tableau');
// Importer les investisseurs du ZIP (créer les manquants, dédupliquer par email puis nom)
const invEntry = zipEntries.find(e => e.name === 'investisseurs.json');
if (invEntry) {
const invList = JSON.parse(invEntry.data.toString('utf8'));
for (const inv of (Array.isArray(invList) ? invList : [])) {
if (!inv.nom) continue;
// Déduplication : priorité à l'email, puis correspondance insensible à la casse sur le nom
const existsByEmail = inv.email
? db.prepare('SELECT id FROM investisseurs WHERE LOWER(email) = LOWER(?) AND user_id = ?').get(inv.email, req.user.id)
: null;
const existsByNom = db.prepare('SELECT id FROM investisseurs WHERE LOWER(nom) = LOWER(?) AND user_id = ?').get(inv.nom, req.user.id);
if (!existsByEmail && !existsByNom) {
db.prepare('INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes, email) VALUES (?,?,?,?,?,?,?)')
.run(req.user.id, inv.nom, inv.prenom ?? null, inv.type || 'famille', inv.type_fiscal ?? null, inv.notes ?? null, inv.email ?? null);
}
}
}
// Résoudre investisseur — priorité email, puis nom+prénom insensible à la casse
const userInvestisseurs = db.prepare('SELECT * FROM investisseurs WHERE user_id = ?').all(req.user.id);
function resolveInvestisseur(nom, prenom, email) {
if (!nom && !email) return userInvestisseurs[0]?.id ?? null;
if (email) {
const byEmail = userInvestisseurs.find(i => i.email?.toLowerCase() === email.toLowerCase());
if (byEmail) return byEmail.id;
}
const match = userInvestisseurs.find(i =>
i.nom?.toLowerCase() === nom?.toLowerCase() && i.prenom?.toLowerCase() === (prenom || '').toLowerCase()
) || userInvestisseurs.find(i => i.nom?.toLowerCase() === nom?.toLowerCase());
return match?.id ?? userInvestisseurs[0]?.id ?? null;
}
// Résoudre categories_inv et secteurs_inv par nom (globaux puis user)
function resolveTagIds(names, table) {
const ids = [];
for (const nom of (names || [])) {
const trimmed = nom.trim();
if (!trimmed) continue;
let row = db.prepare(`SELECT id FROM ${table} WHERE nom = ? AND (user_id IS NULL OR user_id = ?)`).get(trimmed, req.user.id);
if (!row) {
const r = db.prepare(`INSERT INTO ${table} (nom, user_id) VALUES (?, ?)`).run(trimmed, req.user.id);
row = { id: r.lastInsertRowid };
}
ids.push(row.id);
}
return ids;
}
// Résoudre catégories legacy
function resolveLegacyCatIds(noms) {
const ids = [];
for (const nom of (noms || [])) {
const trimmed = nom.trim();
if (!trimmed) continue;
let row = db.prepare('SELECT id FROM categories_plateforme WHERE nom = ? AND user_id = ?').get(trimmed, req.user.id);
if (!row) {
const r = db.prepare('INSERT INTO categories_plateforme (nom, user_id) VALUES (?, ?)').run(trimmed, req.user.id);
row = { id: r.lastInsertRowid };
}
ids.push(row.id);
}
return ids;
}
const imageMap = {};
for (const e of zipEntries) {
if (e.name.startsWith('logos/') && e.name.length > 6) imageMap[path.basename(e.name)] = e.data;
}
let created = 0;
let updated = 0;
const tx = db.transaction(() => {
for (const p of platforms) {
if (!p.nom) continue;
const fiscalite = p.domiciliation === 'FR' ? 'flat_tax' : (p.fiscalite || 'flat_tax');
const taux = fiscalite === 'avec_fiscalite_locale' ? (p.taux_fiscalite_locale ?? null) : null;
const investisseurId = resolveInvestisseur(p.investisseur_nom, p.investisseur_prenom, p.investisseur_email);
const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv');
const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
const legacyCatIds = resolveLegacyCatIds(p.categories);
const existing = db.prepare('SELECT id FROM plateformes WHERE nom = ? AND user_id = ?').get(p.nom, req.user.id);
let platId;
const fields = [
p.url || null, p.notes || null,
p.domiciliation || 'france', fiscalite, taux,
p.type_produit_fiscal || '2TT', p.methode_remboursement || 'portefeuille',
investisseurId, p.date_ouverture || null,
p.type_pret_defaut || null, p.freq_interets_defaut || null,
p.logo_filename || null, p.icone_filename || null,
];
if (existing) {
db.prepare(`
UPDATE plateformes SET url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?,
type_pret_defaut=?, freq_interets_defaut=?,
logo_filename=?, icone_filename=?
WHERE id=? AND user_id=?
`).run(...fields, existing.id, req.user.id);
platId = existing.id;
updated++;
} else {
const r = db.prepare(`
INSERT INTO plateformes
(user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale,
type_produit_fiscal, methode_remboursement, investisseur_id, date_ouverture,
type_pret_defaut, freq_interets_defaut,
logo_filename, icone_filename, overridden_fields)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(req.user.id, p.nom, ...fields, '[]');
platId = r.lastInsertRowid;
created++;
}
// Sync catégories legacy
syncCategories(platId, legacyCatIds);
// Sync categories_inv
db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(platId);
for (const id of catsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?,?)').run(platId, id);
// Sync secteurs_inv
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(platId);
for (const id of sectsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?,?)').run(platId, id);
// Images
for (const fname of [p.logo_filename, p.icone_filename]) {
if (!fname || !imageMap[fname]) continue;
fs.writeFileSync(path.join(logosDir, fname), imageMap[fname]);
}
}
});
tx();
res.json({ ok: true, created, updated, total: platforms.length });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite;
const taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null;
const typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT';
// Récupérer l'état actuel pour calculer les overrides
const current = db.prepare('SELECT referentiel_id, overridden_fields, logo_filename FROM plateformes WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!current) throw new HttpError(404, 'Not found');
// Calculer overridden_fields si la plateforme est liée à un référentiel
let overriddenFields = JSON.parse(current.overridden_fields || '[]');
if (current.referentiel_id) {
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(current.referentiel_id);
const newValues = {
nom: body.nom,
url: body.url || null,
domiciliation: body.domiciliation,
fiscalite,
taux_fiscalite_locale: taux,
type_produit_fiscal: typeProduitFiscal,
logo_filename: current.logo_filename, // logo géré séparément
methode_remboursement: body.methode_remboursement,
type_pret_defaut: body.type_pret_defaut ?? null,
freq_interets_defaut: body.freq_interets_defaut ?? null,
};
overriddenFields = computeOverridden(ref, newValues, overriddenFields);
}
const r = db.prepare(`
UPDATE plateformes
SET nom=?, url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?,
type_pret_defaut=?, freq_interets_defaut=?,
overridden_fields=?
WHERE id=? AND user_id=?
`).run(
body.nom, body.url || null, body.notes || null, body.domiciliation, fiscalite, taux,
typeProduitFiscal, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null,
body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null,
JSON.stringify(overriddenFields),
req.params.id, req.user.id
);
if (r.changes === 0) throw new HttpError(404, 'Not found');
syncCategories(Number(req.params.id), body.categories);
res.json({
id: Number(req.params.id), ...body,
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
referentiel_id: current.referentiel_id,
overridden_fields: overriddenFields,
});
} catch (e) { next(e); }
});
// ── Correction rapide de la date d'ouverture (ex. anomalie détectée à l'import) ──
router.patch('/:id/date-ouverture', (req, res, next) => {
try {
const schema = z.object({ date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) });
const { date_ouverture } = schema.parse(req.body);
const r = db.prepare('UPDATE plateformes SET date_ouverture = ? WHERE id = ? AND user_id = ?')
.run(date_ouverture, req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), date_ouverture });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
// Récupère le logo avant suppression pour effacer le fichier
const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?')
.get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Not found');
// plateforme_id est en ON DELETE CASCADE sur investissements/depots_retraits (nécessaire pour
// permettre la suppression complète d'un compte) — on protège donc ici, explicitement, contre
// la suppression accidentelle d'une plateforme qui a encore des données rattachées.
const { n: nbInv } = db.prepare('SELECT COUNT(*) AS n FROM investissements WHERE plateforme_id = ?').get(req.params.id);
const { n: nbDr } = db.prepare('SELECT COUNT(*) AS n FROM depots_retraits WHERE plateforme_id = ?').get(req.params.id);
if (nbInv > 0 || nbDr > 0) {
throw new HttpError(400, `Impossible de supprimer cette plateforme : elle a encore ${nbInv} investissement(s) et ${nbDr} mouvement(s) de dépôt/retrait enregistrés. Supprimez-les d'abord.`);
}
const r = db.prepare('DELETE FROM plateformes WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
if (plat.logo_filename) {
const filePath = path.join(logosDir, plat.logo_filename);
fs.unlink(filePath, () => {}); // silencieux si déjà supprimé
}
res.status(204).end();
} catch (e) { next(e); }
});
// ── Purge des données d'une plateforme (dépôts/retraits, investissements, remboursements) ──
// La plateforme elle-même n'est jamais supprimée par cette route : seules les données
// rattachées le sont, afin de permettre une réimportation propre derrière.
const PurgeSchema = z.object({
scope: z.enum(['all', 'depots_retraits', 'investissements', 'remboursements']),
confirmNom: z.string().min(1),
});
router.post('/:id/purge-donnees', (req, res, next) => {
try {
const { scope, confirmNom } = PurgeSchema.parse(req.body);
const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
// Sécurité supplémentaire : le nom retapé doit correspondre exactement (protection
// contre une confirmation validée par erreur sur la mauvaise plateforme).
if (confirmNom.trim() !== plat.nom) {
throw new HttpError(400, 'Le nom saisi ne correspond pas exactement au nom de la plateforme.');
}
const platId = plat.id;
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 };
const tx = db.transaction(() => {
if (scope === 'depots_retraits' || scope === 'all') {
counts.depots_retraits = db.prepare('DELETE FROM depots_retraits WHERE plateforme_id = ?').run(platId).changes;
}
if (scope === 'remboursements') {
// Ne touche que les remboursements réels ; les investissements et leur échéancier restent.
counts.remboursements = db.prepare(`
DELETE FROM remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).run(platId).changes;
}
if (scope === 'investissements' || scope === 'all') {
// Compte avant suppression : les remboursements et l'échéancier simulé sont
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
counts.remboursements = db.prepare(`
SELECT COUNT(*) AS n FROM remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).get(platId).n;
counts.simul_remboursements = db.prepare(`
SELECT COUNT(*) AS n FROM simul_remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).get(platId).n;
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
}
});
tx();
res.json({ ok: true, scope, plateforme: plat.nom, counts });
} catch (e) { next(e); }
});
// ── Reset aux valeurs du référentiel ──────────────────────────────────────
router.post('/:id/reset', (req, res, next) => {
try {
const plat = db.prepare('SELECT * FROM plateformes WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Not found');
if (!plat.referentiel_id) throw new HttpError(400, 'Cette plateforme n\'est liée à aucun référentiel');
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(plat.referentiel_id);
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
db.prepare(`
UPDATE plateformes
SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, logo_filename=?, methode_remboursement=?,
type_pret_defaut=?, freq_interets_defaut=?, overridden_fields='[]'
WHERE id=? AND user_id=?
`).run(
ref.nom, ref.url ?? null, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null,
ref.type_produit_fiscal, ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille',
ref.type_pret_defaut ?? null, ref.freq_interets_defaut ?? null,
req.params.id, req.user.id
);
const updated = db.prepare('SELECT * FROM plateformes WHERE id = ?').get(req.params.id);
res.json({ ...updated, overridden_fields: [] });
} catch (e) { next(e); }
});
// ── Upload logo ───────────────────────────────────────────────────────────
router.post('/:id/logo', upload.single('logo'), (req, res, next) => {
try {
if (!req.file) throw new HttpError(400, 'Aucun fichier reçu');
// Supprime l'ancien logo s'il diffère du nouveau
const old = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?')
.get(req.params.id, req.user.id);
if (old?.logo_filename && old.logo_filename !== req.file.filename) {
fs.unlink(path.join(logosDir, old.logo_filename), () => {});
}
db.prepare('UPDATE plateformes SET logo_filename=? WHERE id=? AND user_id=?')
.run(req.file.filename, req.params.id, req.user.id);
res.json({ logo_filename: req.file.filename });
} catch (e) { next(e); }
});
// ── Suppression logo ──────────────────────────────────────────────────────
router.delete('/:id/logo', (req, res, next) => {
try {
const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?')
.get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Not found');
if (plat.logo_filename) {
fs.unlink(path.join(logosDir, plat.logo_filename), () => {});
db.prepare("UPDATE plateformes SET logo_filename=NULL WHERE id=? AND user_id=?")
.run(req.params.id, req.user.id);
}
res.status(204).end();
} catch (e) { next(e); }
});
export default router;