Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
data
uploads
.env
.env.*
npm-debug.log
*.log
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p /app/data /app/uploads
EXPOSE 4000
CMD ["node", "src/server.js"]
+72
View File
@@ -0,0 +1,72 @@
/**
* Diagnostic rapide de la base de données crowdlending
* Exécuter depuis D:\dev\crowdlending-app\backend :
* node diag.mjs
*/
import Database from 'better-sqlite3';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = path.resolve(__dirname, 'data/crowdlending.db');
const db = new Database(DB_PATH, { readonly: true });
console.log('\n=== COMPTES PAR TABLE ===');
for (const t of ['users','investisseurs','investissements','plateformes','remboursements','simul_remboursements','depots_retraits','investissement_historique']) {
try {
const n = db.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n;
console.log(` ${t.padEnd(30)} ${n} lignes`);
} catch (e) {
console.log(` ${t.padEnd(30)} ERREUR: ${e.message}`);
}
}
console.log('\n=== TABLES __repair_* RESTANTES ===');
const repairTables = db.prepare("SELECT name FROM sqlite_master WHERE name LIKE '__repair_%'").all();
if (repairTables.length === 0) console.log(' (aucune)');
else repairTables.forEach(r => console.log(` ${r.name}`));
console.log('\n=== USERS ===');
db.prepare('SELECT id, email FROM users').all().forEach(r =>
console.log(` user id=${r.id} email=${r.email}`)
);
console.log('\n=== INVESTISSEURS ===');
db.prepare('SELECT id, nom, user_id FROM investisseurs').all().forEach(r =>
console.log(` investisseur id=${r.id} nom="${r.nom}" user_id=${r.user_id}`)
);
console.log('\n=== INVESTISSEMENTS (5 premiers) ===');
const invs = db.prepare('SELECT id, nom_projet, investisseur_id, statut FROM investissements LIMIT 5').all();
if (invs.length === 0) console.log(' (TABLE VIDE!)');
else invs.forEach(r =>
console.log(` inv id=${r.id} investisseur_id=${r.investisseur_id} statut=${r.statut} projet="${r.nom_projet}"`)
);
console.log('\n=== VÉRIFICATION FK investissements → investisseurs ===');
const broken = db.prepare(`
SELECT COUNT(*) AS n FROM investissements i
WHERE NOT EXISTS (SELECT 1 FROM investisseurs v WHERE v.id = i.investisseur_id)
`).get().n;
console.log(broken === 0 ? ' OK (pas de FK cassée)' : ` ⚠️ ${broken} investissements avec investisseur_id introuvable!`);
console.log('\n=== VÉRIFICATION FK remboursements → investissements ===');
const broken2 = db.prepare(`
SELECT COUNT(*) AS n FROM remboursements r
WHERE NOT EXISTS (SELECT 1 FROM investissements i WHERE i.id = r.investissement_id)
`).get().n;
console.log(broken2 === 0 ? ' OK' : ` ⚠️ ${broken2} remboursements avec investissement_id introuvable!`);
console.log('\n=== CHECK CONSTRAINT de investissements ===');
const schemaInv = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'").get()?.sql ?? '';
const checkLine = schemaInv.split('\n').find(l => l.includes('statut') && l.includes('CHECK'));
console.log(' ', checkLine?.trim() ?? '(non trouvé)');
console.log('\n=== RÉFÉRENCES _investissements_old RESTANTES ===');
const refs = db.prepare("SELECT type, name FROM sqlite_master WHERE sql LIKE '%_investissements_old%'").all();
if (refs.length === 0) console.log(' (aucune) ✓');
else refs.forEach(r => console.log(` [${r.type}] ${r.name}`));
db.close();
console.log('\nDiagnostic terminé.\n');
+67
View File
@@ -0,0 +1,67 @@
/**
* fix_dates_cible.mjs
* Corrige les date_cible aberrantes (>2100) en recalculant date_souscription + duree_mois
* et régénère les simul_remboursements correspondantes.
* Usage : node fix_dates_cible.mjs
*/
import Database from 'better-sqlite3';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateSimul } from './src/utils/schedule.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH || path.resolve(__dirname, 'data/crowdlending.db');
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
function addMonths(isoDate, months) {
const [y, m, d] = isoDate.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1 + months, d));
return dt.toISOString().slice(0, 10);
}
const rows = db.prepare(`
SELECT i.id, i.nom_projet, p.nom AS plateforme,
i.date_souscription, i.date_cible, i.duree_mois,
i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets,
i.date_premiere_echeance, i.date_debut_simul, i.echeance_fin_de_mois
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.statut IN ('en_cours','en_retard','procedure')
AND i.type_remb = 'differe'
AND i.date_cible > '2100-01-01'
`).all();
console.log(`${rows.length} investissements à corriger\n`);
const update = db.prepare(`UPDATE investissements SET date_cible=?, updated_at=datetime('now') WHERE id=?`);
const fix = db.transaction(() => {
for (const row of rows) {
const newDate = addMonths(row.date_souscription, row.duree_mois);
update.run(newDate, row.id);
console.log(`[${row.id}] ${row.plateforme}${row.nom_projet}`);
console.log(` ${row.date_cible}${newDate}`);
generateSimul(db, {
id: row.id,
montant_investi: row.montant_investi,
taux_interet: row.taux_interet,
duree_mois: row.duree_mois,
type_remb: row.type_remb,
freq_interets: row.freq_interets,
date_premiere_echeance: row.date_premiere_echeance,
date_debut_simul: row.date_debut_simul,
date_souscription: row.date_souscription,
echeance_fin_de_mois: row.echeance_fin_de_mois ?? 0,
});
console.log(` ✓ simulation régénérée`);
}
});
fix();
console.log('\nTerminé.');
db.close();
+2296
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "crowdlending-backend",
"version": "0.1.0",
"description": "Backend API for crowdlending tracker",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"db:init": "node src/db/init.js",
"db:seed": "node src/db/seed.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"sharp": "^0.34.5",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
// Standalone DB initializer (run with: npm run db:init)
import 'dotenv/config';
import db from './index.js';
console.log('SQLite database initialized.');
console.log(`Tables: ${db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all()
.map(r => r.name)
.join(', ')}`);
db.close();
+253
View File
@@ -0,0 +1,253 @@
-- =====================================================================
-- Crowdlending Tracker - SQLite schema
-- =====================================================================
-- Conventions:
-- * Monetary amounts: REAL (cents-level precision OK for personal use)
-- * Dates: TEXT in ISO 8601 (YYYY-MM-DD) for stable sort & SQLite date()
-- * All tables use AUTOINCREMENT-free INTEGER PK (rowid alias)
-- * created_at/updated_at: TEXT ISO timestamp, set by app
-- =====================================================================
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
-- ---------------------------------------------------------------------
-- USERS (login accounts)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ---------------------------------------------------------------------
-- INVESTISSEURS (profils d'investissement sous un même login)
-- Ex.: "Monsieur", "Madame", "SCI Croguennec", "PEA-PME", ...
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS investisseurs (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nom TEXT NOT NULL, -- nom complet (famille) ou raison sociale (entreprise)
prenom TEXT, -- prénom (famille uniquement)
type TEXT NOT NULL DEFAULT 'famille'
CHECK(type IN ('famille','entreprise')),
type_fiscal TEXT, -- 'PP', 'PM', 'SCI', 'SCPI'...
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, nom)
);
CREATE INDEX IF NOT EXISTS idx_investisseurs_user ON investisseurs(user_id);
-- ---------------------------------------------------------------------
-- PLATEFORMES (ClubFunding, October, Lendix, La Première Brique, ...)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS plateformes (
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)
);
CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id);
-- ---------------------------------------------------------------------
-- DEPOTS_RETRAITS (mouvements de cash sur les plateformes)
-- type: 'depot' (versement) | 'retrait' (retrait)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS depots_retraits (
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 RESTRICT,
date_operation TEXT NOT NULL, -- ISO YYYY-MM-DD
type TEXT NOT NULL CHECK(type IN ('depot','retrait')),
montant REAL NOT NULL CHECK(montant >= 0),
libelle TEXT,
reference TEXT, -- ref bancaire / plateforme
source TEXT NOT NULL DEFAULT 'manuel', -- 'manuel' | 'import_excel'
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
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);
-- ---------------------------------------------------------------------
-- INVESTISSEMENTS (CF Investissements - liste des projets souscrits)
-- statut: 'en_cours' | 'rembourse' | 'en_retard' | 'procedure' | 'cloture'
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS investissements (
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 RESTRICT,
nom_projet TEXT NOT NULL,
emetteur TEXT, -- nom de la société emprunteuse
date_souscription TEXT NOT NULL,
date_premiere_echeance TEXT, -- date de la 1ère échéance (intérêts / remboursement)
date_cible TEXT, -- date contractuelle du dernier versement (calculée)
montant_investi REAL NOT NULL CHECK(montant_investi > 0),
taux_interet REAL, -- en % annuel (ex. 9.5)
duree_mois INTEGER,
type_remb TEXT, -- 'in_fine' | 'amortissable' | 'differe'
freq_interets TEXT NOT NULL DEFAULT 'mensuel', -- 'mensuel' | 'trimestriel' | 'in_fine'
statut TEXT NOT NULL DEFAULT 'en_cours'
CHECK(statut IN ('en_cours','rembourse','en_retard','procedure','cloture')),
reference TEXT, -- ID projet sur la plateforme
source TEXT NOT NULL DEFAULT 'manuel',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
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);
-- ---------------------------------------------------------------------
-- REMBOURSEMENTS (échéances perçues, réelles)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS remboursements (
id INTEGER PRIMARY KEY,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
date_remb TEXT NOT NULL,
capital REAL NOT NULL DEFAULT 0,
interets_bruts REAL NOT NULL DEFAULT 0, -- intérêts AVANT prélèvements
prelev_sociaux REAL NOT NULL DEFAULT 0, -- 17.2 % typiquement
prelev_forfaitaire REAL NOT NULL DEFAULT 0, -- 12.8 % (PFU IR)
cashback REAL NOT NULL DEFAULT 0, -- remboursement non taxé (bonus plateforme, etc.)
interets_nets REAL NOT NULL DEFAULT 0, -- interets_bruts - prelev_sociaux - prelev_forfaitaire
net_recu REAL NOT NULL DEFAULT 0, -- capital + cashback + interets_nets
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'))
);
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);
-- ---------------------------------------------------------------------
-- SIMUL_REMBOURSEMENTS (échéances prévisionnelles / théoriques)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS simul_remboursements (
id INTEGER PRIMARY KEY,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
numero_echeance INTEGER NOT NULL,
date_prevue TEXT NOT NULL,
capital_prevu REAL NOT NULL DEFAULT 0,
interets_prevus REAL NOT NULL DEFAULT 0,
total_prevu REAL NOT NULL DEFAULT 0,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(investissement_id, numero_echeance)
);
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);
-- ---------------------------------------------------------------------
-- IMPORTS (historique des imports Excel pour traçabilité)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS imports (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
module TEXT NOT NULL, -- 'depots_retraits' | 'investissements' | ...
filename TEXT NOT NULL,
rows_total INTEGER NOT NULL DEFAULT 0,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_skipped INTEGER NOT NULL DEFAULT 0,
mapping_json TEXT, -- JSON: mappage colonnes
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_imports_user ON imports(user_id);
-- ---------------------------------------------------------------------
-- CATEGORIES_PLATEFORME (tags libres associés à une plateforme)
-- Semées par défaut à la première utilisation (voir route /categories)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS categories_plateforme (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, nom)
);
CREATE INDEX IF NOT EXISTS idx_catplat_user ON categories_plateforme(user_id);
-- Junction : une plateforme peut avoir plusieurs catégories
CREATE TABLE IF NOT EXISTS plateforme_categories (
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
categorie_id INTEGER NOT NULL REFERENCES categories_plateforme(id) ON DELETE CASCADE,
PRIMARY KEY(plateforme_id, categorie_id)
);
-- ---------------------------------------------------------------------
-- TAUX_PFU (historique Flat Tax France — table de référence globale)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS taux_pfu (
id INTEGER PRIMARY KEY,
annee INTEGER NOT NULL UNIQUE,
pfu_total REAL NOT NULL, -- ex. 30.0
impot_revenu REAL NOT NULL, -- ex. 12.8
prelev_sociaux REAL NOT NULL, -- ex. 17.2
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_pfu_annee ON taux_pfu(annee);
-- ---------------------------------------------------------------------
-- VUES utiles
-- ---------------------------------------------------------------------
-- Solde courant par (investisseur, plateforme)
CREATE VIEW IF NOT EXISTS v_solde_plateforme AS
SELECT
dr.investisseur_id,
dr.plateforme_id,
SUM(CASE WHEN dr.type='depot' THEN dr.montant ELSE 0 END) AS total_depots,
SUM(CASE WHEN dr.type='retrait' THEN dr.montant ELSE 0 END) AS total_retraits,
SUM(CASE WHEN dr.type='depot' THEN dr.montant
WHEN dr.type='retrait' THEN -dr.montant END) AS solde_net
FROM depots_retraits dr
GROUP BY dr.investisseur_id, dr.plateforme_id;
-- Synthèse investissements par investisseur
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;
-- Intérêts perçus par année (pour le 2778-SD)
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);
+124
View File
@@ -0,0 +1,124 @@
import db from '../db/index.js';
const JOB_NAME = 'auto_statut_retard';
/** Persiste une entrée dans job_logs */
function writeLog({ status, nbChanges, details, errorMsg }) {
try {
db.prepare(`
INSERT INTO job_logs (job_name, status, nb_changes, details, error_msg)
VALUES (?, ?, ?, ?, ?)
`).run(JOB_NAME, status, nbChanges ?? 0, details ?? null, errorMsg ?? null);
} catch (e) {
console.error('[autoStatut] Impossible d\'écrire dans job_logs :', e.message);
}
}
/**
* Passe automatiquement au statut "en_retard" les investissements dont :
* - le statut est actuellement "en_cours"
* - la date_cible est renseignée et strictement antérieure à aujourd'hui
*
* Chaque passage est tracé dans investissement_historique avec le
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité.
*
* @returns {number} nombre d'investissements mis à jour
*/
export function checkStatutsRetard() {
const candidats = db.prepare(`
SELECT id, nom_projet, date_cible
FROM investissements
WHERE statut = 'en_cours'
AND date_cible IS NOT NULL
AND date_cible < date('now')
`).all();
if (candidats.length === 0) {
writeLog({ status: 'ok', nbChanges: 0, details: 'Aucun investissement en retard détecté' });
return 0;
}
const updateStmt = db.prepare(`
UPDATE investissements
SET statut = 'en_retard', updated_at = datetime('now')
WHERE id = ?
`);
const histStmt = db.prepare(`
INSERT INTO investissement_historique
(investissement_id, type_evenement, changements, notes)
VALUES (?, 'passage_auto_retard', ?, ?)
`);
const tx = db.transaction(() => {
for (const inv of candidats) {
updateStmt.run(inv.id);
histStmt.run(
inv.id,
JSON.stringify([{
champ: 'statut',
label: 'Statut',
ancienne_valeur: 'en_cours',
nouvelle_valeur: 'en_retard',
}]),
`Passage automatique : date cible (${inv.date_cible}) dépassée`
);
}
});
tx();
const details = candidats
.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`)
.join('; ');
writeLog({
status: 'ok',
nbChanges: candidats.length,
details: `Passé en retard : ${details}`,
});
console.log(`[autoStatut] ${candidats.length} investissement(s) passé(s) en retard : ${details}`);
return candidats.length;
}
/**
* Démarre le job de vérification automatique des statuts.
*
* - Exécution immédiate au démarrage (rattrape les retards accumulés
* pendant que le serveur était éteint).
* - Puis répétition quotidienne, calée sur la prochaine minuit locale
* afin de ne pas dériver au fil des redémarrages.
*/
export function startAutoStatutJob() {
// Exécution initiale
try {
checkStatutsRetard();
} catch (err) {
console.error('[autoStatut] Erreur lors de la vérification initiale :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
// Calcule le délai jusqu'à la prochaine minuit locale
function msUntilMidnight() {
const now = new Date();
const next = new Date(now);
next.setHours(24, 0, 0, 0);
return next.getTime() - now.getTime();
}
// Planifie la première échéance à minuit, puis toutes les 24h (sans dérive)
function scheduleDailyRun() {
setTimeout(() => {
try {
checkStatutsRetard();
} catch (err) {
console.error('[autoStatut] Erreur lors de la vérification quotidienne :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
scheduleDailyRun();
}, msUntilMidnight());
}
scheduleDailyRun();
console.log(`[autoStatut] Job démarré — prochaine vérification dans ${Math.round(msUntilMidnight() / 60000)} min (minuit)`);
}
+35
View File
@@ -0,0 +1,35 @@
import jwt from 'jsonwebtoken';
import db from '../db/index.js';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
export function signToken(payload) {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
});
}
export function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = { id: decoded.sub, email: decoded.email };
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
/** À utiliser après requireAuth. Refuse l'accès si l'utilisateur n'est pas admin. */
export function requireAdmin(req, res, next) {
const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
if (!row || row.role !== 'admin') {
return res.status(403).json({ error: 'Accès réservé aux administrateurs' });
}
req.user.role = 'admin';
next();
}
+23
View File
@@ -0,0 +1,23 @@
import { ZodError } from 'zod';
// eslint-disable-next-line no-unused-vars
export function errorHandler(err, req, res, next) {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'Validation error',
details: err.flatten(),
});
}
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
@@ -0,0 +1,22 @@
import db from '../db/index.js';
import { HttpError } from './errorHandler.js';
/**
* Resolves the active investisseur from the X-Investisseur-Id header (or query),
* verifies it belongs to the authenticated user, and exposes it as req.investisseur.
*
* Routes that operate on investisseur-scoped data must use this middleware.
*/
export function requireInvestisseur(req, res, next) {
const raw = req.header('X-Investisseur-Id') || req.query.investisseurId;
const id = Number(raw);
if (!id) throw new HttpError(400, 'Missing investisseur id (header X-Investisseur-Id)');
const row = db
.prepare('SELECT id, nom, type_fiscal FROM investisseurs WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!row) throw new HttpError(403, 'Investisseur not found or not owned by user');
req.investisseur = row;
next();
}
+373
View File
@@ -0,0 +1,373 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { checkStatutsRetard } from '../jobs/autoStatut.js';
// ── Helpers similarité de noms ──────────────────────────────────────────── */
/** Distance de Levenshtein entre deux chaînes */
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
/** Similarité normalisée [0..1] — insensible à la casse et aux accents */
function normalize(s) {
return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
}
function similarity(a, b) {
const na = normalize(a), nb = normalize(b);
if (!na && !nb) return 1;
const maxLen = Math.max(na.length, nb.length);
return maxLen === 0 ? 1 : 1 - levenshtein(na, nb) / maxLen;
}
const SIMILARITY_THRESHOLD = 0.80;
// Registre des jobs disponibles (nom → fonction synchrone)
const JOBS = {
auto_statut_retard: checkStatutsRetard,
};
const router = Router();
// requireAuth + requireAdmin sont appliqués dans server.js avant ce router
/* ── Utilisateurs ─────────────────────────────────────────────────────── */
/** Liste tous les utilisateurs */
router.get('/users', (req, res) => {
const users = db.prepare(`
SELECT id, email, display_name, role, created_at
FROM users
ORDER BY id ASC
`).all();
res.json(users);
});
/** Crée un utilisateur */
const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(1).optional(),
role: z.enum(['user', 'admin']).default('user'),
});
router.post('/users', (req, res, next) => {
try {
const body = CreateUserSchema.parse(req.body);
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email);
if (exists) throw new HttpError(409, 'Email déjà utilisé');
const hash = bcrypt.hashSync(body.password, 10);
const result = db
.prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
.run(body.email, hash, body.displayName || null, body.role);
const userId = result.lastInsertRowid;
const fullName = body.displayName || body.email.split('@')[0];
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal) VALUES (?, ?, ?, 'famille', 'PP')`
).run(userId, fullName, prenom);
res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role });
} catch (e) { next(e); }
});
/** Modifie le rôle d'un utilisateur */
const PatchRoleSchema = z.object({
role: z.enum(['user', 'admin']),
});
router.patch('/users/:id/role', (req, res, next) => {
try {
const { role } = PatchRoleSchema.parse(req.body);
const targetId = Number(req.params.id);
// Empêche un admin de se rétrograder lui-même
if (targetId === req.user.id && role !== 'admin') {
throw new HttpError(400, 'Vous ne pouvez pas vous rétrograder vous-même');
}
const r = db.prepare("UPDATE users SET role=?, updated_at=datetime('now') WHERE id=?")
.run(role, targetId);
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
res.json({ id: targetId, role });
} catch (e) { next(e); }
});
/** Supprime un utilisateur (sauf soi-même) */
router.delete('/users/:id', (req, res, next) => {
try {
const targetId = Number(req.params.id);
if (targetId === req.user.id) {
throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte');
}
const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId);
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
res.status(204).end();
} catch (e) { next(e); }
});
/* ── Logs des jobs ────────────────────────────────────────────────────── */
router.get('/job-logs', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const offset = Number(req.query.offset) || 0;
const job = req.query.job || null;
const conds = job ? 'WHERE job_name = ?' : '';
const params = job ? [job, limit, offset] : [limit, offset];
const rows = db.prepare(`
SELECT id, job_name, run_at, status, nb_changes, details, error_msg
FROM job_logs
${conds}
ORDER BY run_at DESC
LIMIT ? OFFSET ?
`).all(...params);
const total = db.prepare(`SELECT COUNT(*) AS n FROM job_logs ${conds}`)
.get(...(job ? [job] : [])).n;
res.json({ total, rows });
});
/* ── Plateformes orphelines (sans référentiel) ───────────────────────── */
/** Liste les plateformes user sans referentiel_id, avec suggestion de liaison si nom similaire */
router.get('/plateformes-orphelines', (_req, res) => {
const rows = db.prepare(`
SELECT p.id, p.nom, p.domiciliation, p.fiscalite, p.created_at,
u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name
FROM plateformes p
JOIN users u ON u.id = p.user_id
WHERE p.referentiel_id IS NULL
ORDER BY u.email, p.nom
`).all();
// Charger le référentiel pour calculer les suggestions de liaison
const refs = db.prepare('SELECT id, nom FROM plateformes_referentiel ORDER BY nom').all();
const enriched = rows.map(plat => {
let bestRef = null, bestScore = 0;
for (const ref of refs) {
const score = similarity(plat.nom, ref.nom);
if (score > bestScore) { bestScore = score; bestRef = ref; }
}
return {
...plat,
suggestion: bestScore >= SIMILARITY_THRESHOLD
? { referentiel_id: bestRef.id, referentiel_nom: bestRef.nom, score: Math.round(bestScore * 100) }
: null,
};
});
res.json(enriched);
});
/** Importe une plateforme orpheline dans le référentiel et la lie */
router.post('/plateformes-orphelines/:id/importer', (req, res, next) => {
try {
const plat = db.prepare(`
SELECT p.*, u.email AS user_email
FROM plateformes p
JOIN users u ON u.id = p.user_id
WHERE p.id = ?
`).get(req.params.id);
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
if (plat.referentiel_id) throw new HttpError(409, 'Plateforme deja liee a un referentiel');
// Verifier si un referentiel avec ce nom existe deja
const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(plat.nom);
if (existing) {
// Lier simplement la plateforme au referentiel existant
db.prepare(
"UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?"
).run(existing.id, plat.id);
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(existing.id);
return res.json({ linked: true, created: false, referentiel: ref });
}
// Creer un nouveau referentiel depuis la plateforme
db.transaction(() => {
const r = db.prepare(`
INSERT INTO plateformes_referentiel
(nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, logo_filename, updated_at)
VALUES (?,?,?,?,?,?,?, datetime('now'))
`).run(
plat.nom, plat.url || null, plat.domiciliation, plat.fiscalite,
plat.taux_fiscalite_locale ?? null, plat.type_produit_fiscal || '2TT',
plat.logo_filename ?? null
);
const refId = r.lastInsertRowid;
// Copier les categories (noms) depuis la plateforme source
const cats = db.prepare(`
SELECT cp.nom FROM plateforme_categories pc
JOIN categories_plateforme cp ON cp.id = pc.categorie_id
WHERE pc.plateforme_id = ?
`).all(plat.id);
const insC = db.prepare('INSERT OR IGNORE INTO referentiel_categories (referentiel_id, categorie_nom) VALUES (?,?)');
for (const c of cats) insC.run(refId, c.nom);
// Copier les criteres de notation
const notations = db.prepare('SELECT * FROM notation_criteres WHERE plateforme_id = ?').all(plat.id);
const insN = db.prepare(`
INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre)
VALUES (?,?,?,?,?,?,?,?)
`);
for (const n of notations) insN.run(refId, n.nom, n.type, n.valeurs, n.min_val, n.max_val, n.description, n.ordre);
// Lier la plateforme au nouveau referentiel, overridden_fields vide
db.prepare(
"UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?"
).run(refId, plat.id);
})();
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE nom = ?').get(plat.nom);
res.status(201).json({ linked: true, created: true, referentiel: ref });
} catch (e) { next(e); }
});
/** Lie directement une plateforme orpheline à un référentiel existant (sans import) */
router.post('/plateformes-orphelines/:id/lier', (req, res, next) => {
try {
const { referentiel_id } = req.body;
if (!referentiel_id) throw new HttpError(400, 'referentiel_id requis');
const plat = db.prepare('SELECT id, referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
if (plat.referentiel_id) throw new HttpError(409, 'Plateforme déjà liée à un référentiel');
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentiel_id);
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
db.prepare(
"UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?"
).run(ref.id, plat.id);
res.json({ linked: true, referentiel: ref });
} catch (e) { next(e); }
});
/* -- Execution manuelle d'un job ----------------------------------------- */
router.post('/jobs/:name/run', (req, res, next) => {
try {
const { name } = req.params;
const fn = JOBS[name];
if (!fn) throw new HttpError(404, `Job inconnu : ${name}`);
const nbChanges = fn();
const lastLog = db.prepare(
'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1'
).get(name);
res.json({ ok: true, nb_changes: nbChanges, log: lastLog });
} catch (e) { next(e); }
});
export default router;
/* ── Catégories & secteurs suggérés par les utilisateurs ─────────────── */
/** Compte les catégories et secteurs créés par les utilisateurs (non globaux) */
router.get('/inv-suggestions-count', (_req, res) => {
const cats = db.prepare('SELECT COUNT(*) AS n FROM categories_inv WHERE user_id IS NOT NULL').get().n;
const sects = db.prepare('SELECT COUNT(*) AS n FROM secteurs_inv WHERE user_id IS NOT NULL').get().n;
res.json({ cats, sects, total: cats + sects });
});
/** Liste toutes les catégories et secteurs créés par les utilisateurs */
router.get('/inv-suggestions', (_req, res) => {
const categories = db.prepare(`
SELECT c.id, c.nom, c.user_id, u.email, u.display_name,
(SELECT COUNT(*) FROM plateforme_categories_inv WHERE categorie_id = c.id) AS nb_plateformes,
(SELECT COUNT(*) FROM investissement_categories_inv WHERE categorie_id = c.id) AS nb_investissements
FROM categories_inv c
JOIN users u ON u.id = c.user_id
WHERE c.user_id IS NOT NULL
ORDER BY u.email, c.nom
`).all();
const secteurs = db.prepare(`
SELECT s.id, s.nom, s.user_id, u.email, u.display_name,
(SELECT COUNT(*) FROM plateforme_secteurs_inv WHERE secteur_id = s.id) AS nb_plateformes,
(SELECT COUNT(*) FROM investissement_secteurs_inv WHERE secteur_id = s.id) AS nb_investissements
FROM secteurs_inv s
JOIN users u ON u.id = s.user_id
WHERE s.user_id IS NOT NULL
ORDER BY u.email, s.nom
`).all();
res.json({ categories, secteurs });
});
/** Promeut une catégorie utilisateur en catégorie globale (user_id → NULL) */
router.post('/inv-suggestions/categories/:id/promouvoir', (req, res, next) => {
try {
const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
if (row.user_id === null) throw new HttpError(400, 'Déjà globale');
const dup = db.prepare('SELECT id FROM categories_inv WHERE nom = ? AND user_id IS NULL').get(row.nom);
if (dup) throw new HttpError(409, `Une catégorie globale "${row.nom}" existe déjà.`);
db.prepare('UPDATE categories_inv SET user_id = NULL WHERE id = ?').run(row.id);
res.json({ ok: true, msg: `"${row.nom}" promue en catégorie globale.` });
} catch (e) { next(e); }
});
/** Supprime une catégorie suggérée */
router.delete('/inv-suggestions/categories/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer une catégorie globale depuis cette route.');
db.transaction(() => {
db.prepare('DELETE FROM plateforme_categories_inv WHERE categorie_id = ?').run(row.id);
db.prepare('DELETE FROM investissement_categories_inv WHERE categorie_id = ?').run(row.id);
db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id);
})();
res.json({ ok: true, msg: `"${row.nom}" supprimée.` });
} catch (e) { next(e); }
});
/** Promeut un secteur utilisateur en secteur global */
router.post('/inv-suggestions/secteurs/:id/promouvoir', (req, res, next) => {
try {
const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Secteur introuvable');
if (row.user_id === null) throw new HttpError(400, 'Déjà global');
const dup = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ? AND user_id IS NULL').get(row.nom);
if (dup) throw new HttpError(409, `Un secteur global "${row.nom}" existe déjà.`);
db.prepare('UPDATE secteurs_inv SET user_id = NULL WHERE id = ?').run(row.id);
res.json({ ok: true, msg: `"${row.nom}" promu en secteur global.` });
} catch (e) { next(e); }
});
/** Supprime un secteur suggéré */
router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Secteur introuvable');
if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer un secteur global depuis cette route.');
db.transaction(() => {
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE secteur_id = ?').run(row.id);
db.prepare('DELETE FROM investissement_secteurs_inv WHERE secteur_id = ?').run(row.id);
db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id);
})();
res.json({ ok: true, msg: `"${row.nom}" supprimé.` });
} catch (e) { next(e); }
});
+281
View File
@@ -0,0 +1,281 @@
/**
* Routes associations catégories/secteurs pour plateformes et investissements.
*
* GET /api/plateformes/:id/categories-inv
* PUT /api/plateformes/:id/categories-inv { ids: [1,2,3] }
* GET /api/plateformes/:id/secteurs-inv
* PUT /api/plateformes/:id/secteurs-inv { ids: [1,2,3] }
*
* GET /api/investissements/:id/categories-inv
* PUT /api/investissements/:id/categories-inv { ids: [1,2,3] }
* GET /api/investissements/:id/secteurs-inv
* PUT /api/investissements/:id/secteurs-inv { ids: [1,2,3] }
*
* Toutes ces routes sont montées sous requireAuth.
* La vérification de propriété (via investisseur.user_id) est faite sur chaque route.
*/
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
// ── Helpers ────────────────────────────────────────────────────────────────
/** Vérifie que la plateforme appartient à l'utilisateur connecté. */
function checkPlatOwner(platId, userId) {
const row = db.prepare(`
SELECT p.id FROM plateformes p
JOIN investisseurs i ON i.id = p.investisseur_id
WHERE p.id = ? AND i.user_id = ?
`).get(platId, userId);
if (!row) throw new HttpError(404, 'Plateforme introuvable ou accès refusé.');
return row;
}
/** Vérifie que l'investissement appartient à l'utilisateur connecté. */
function checkInvOwner(invId, userId) {
const row = db.prepare(`
SELECT inv.id, inv.plateforme_id FROM investissements inv
JOIN investisseurs i ON i.id = inv.investisseur_id
WHERE inv.id = ? AND i.user_id = ?
`).get(invId, userId);
if (!row) throw new HttpError(404, 'Investissement introuvable ou accès refusé.');
return row;
}
/** Vérifie que les ids fournis sont accessibles à l'utilisateur (globaux ou privés). */
function checkCatIds(ids, userId) {
for (const id of ids) {
const row = db.prepare(
'SELECT id FROM categories_inv WHERE id = ? AND (user_id IS NULL OR user_id = ?)'
).get(id, userId);
if (!row) throw new HttpError(400, `Catégorie ${id} introuvable ou non autorisée.`);
}
}
function checkSectIds(ids, userId) {
for (const id of ids) {
const row = db.prepare(
'SELECT id FROM secteurs_inv WHERE id = ? AND (user_id IS NULL OR user_id = ?)'
).get(id, userId);
if (!row) throw new HttpError(400, `Secteur ${id} introuvable ou non autorisé.`);
}
}
const IdsSchema = z.object({ ids: z.array(z.number().int()).default([]) });
// ── Plateforme → catégories ────────────────────────────────────────────────
router.get('/plateformes/:id/categories-inv', (req, res, next) => {
try {
checkPlatOwner(req.params.id, req.user.id);
const rows = db.prepare(`
SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global, 0 AS is_inherited
FROM plateforme_categories_inv pc
JOIN categories_inv c ON c.id = pc.categorie_id
WHERE pc.plateforme_id = ?
ORDER BY is_global DESC, c.nom
`).all(req.params.id);
// Merge tags du référentiel avec is_inherited: 1
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
if (plat?.referentiel_id) {
const refCats = db.prepare(`
SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM referentiel_categories_inv rc
JOIN categories_inv c ON c.id = rc.categorie_id
WHERE rc.referentiel_id = ?
`).all(plat.referentiel_id);
const refIdSet = new Set(refCats.map(r => r.id));
for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1;
const ownIds = new Set(rows.map(r => r.id));
for (const rc of refCats) {
if (!ownIds.has(rc.id)) rows.push({ ...rc, is_inherited: 1 });
}
rows.sort((a, b) => a.nom.localeCompare(b.nom));
}
res.json(rows);
} catch (e) { next(e); }
});
router.put('/plateformes/:id/categories-inv', (req, res, next) => {
try {
const { ids } = IdsSchema.parse(req.body);
const userId = req.user.id;
checkPlatOwner(req.params.id, userId);
checkCatIds(ids, userId);
// Toujours inclure les tags hérités du référentiel
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
let allIds = [...ids];
if (plat?.referentiel_id) {
const refCatIds = db.prepare('SELECT categorie_id FROM referentiel_categories_inv WHERE referentiel_id = ?')
.all(plat.referentiel_id).map(r => r.categorie_id);
allIds = [...new Set([...refCatIds, ...ids])];
}
db.transaction(() => {
db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(req.params.id);
const ins = db.prepare('INSERT INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?, ?)');
for (const id of allIds) ins.run(req.params.id, id);
syncInvestissementsCategories(req.params.id, allIds);
})();
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Plateforme → secteurs ──────────────────────────────────────────────────
router.get('/plateformes/:id/secteurs-inv', (req, res, next) => {
try {
checkPlatOwner(req.params.id, req.user.id);
const rows = db.prepare(`
SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global, 0 AS is_inherited
FROM plateforme_secteurs_inv ps
JOIN secteurs_inv s ON s.id = ps.secteur_id
WHERE ps.plateforme_id = ?
ORDER BY is_global DESC, s.nom
`).all(req.params.id);
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
if (plat?.referentiel_id) {
const refSects = db.prepare(`
SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM referentiel_secteurs_inv rs
JOIN secteurs_inv s ON s.id = rs.secteur_id
WHERE rs.referentiel_id = ?
`).all(plat.referentiel_id);
const refIdSet = new Set(refSects.map(r => r.id));
for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1;
const ownIds = new Set(rows.map(r => r.id));
for (const rs of refSects) {
if (!ownIds.has(rs.id)) rows.push({ ...rs, is_inherited: 1 });
}
rows.sort((a, b) => a.nom.localeCompare(b.nom));
}
res.json(rows);
} catch (e) { next(e); }
});
router.put('/plateformes/:id/secteurs-inv', (req, res, next) => {
try {
const { ids } = IdsSchema.parse(req.body);
const userId = req.user.id;
checkPlatOwner(req.params.id, userId);
checkSectIds(ids, userId);
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
let allIds = [...ids];
if (plat?.referentiel_id) {
const refSectIds = db.prepare('SELECT secteur_id FROM referentiel_secteurs_inv WHERE referentiel_id = ?')
.all(plat.referentiel_id).map(r => r.secteur_id);
allIds = [...new Set([...refSectIds, ...ids])];
}
db.transaction(() => {
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(req.params.id);
const ins = db.prepare('INSERT INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?, ?)');
for (const id of allIds) ins.run(req.params.id, id);
syncInvestissementsSecteurs(req.params.id, allIds);
})();
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Investissement → catégories ────────────────────────────────────────────
router.get('/investissements/:id/categories-inv', (req, res, next) => {
try {
checkInvOwner(req.params.id, req.user.id);
const rows = db.prepare(`
SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM investissement_categories_inv ic
JOIN categories_inv c ON c.id = ic.categorie_id
WHERE ic.investissement_id = ?
ORDER BY is_global DESC, c.nom
`).all(req.params.id);
res.json(rows);
} catch (e) { next(e); }
});
router.put('/investissements/:id/categories-inv', (req, res, next) => {
try {
const { ids } = IdsSchema.parse(req.body);
const userId = req.user.id;
checkInvOwner(req.params.id, userId);
checkCatIds(ids, userId);
db.transaction(() => {
db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?').run(req.params.id);
const ins = db.prepare('INSERT INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)');
for (const id of ids) ins.run(req.params.id, id);
})();
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Investissement → secteurs ──────────────────────────────────────────────
router.get('/investissements/:id/secteurs-inv', (req, res, next) => {
try {
checkInvOwner(req.params.id, req.user.id);
const rows = db.prepare(`
SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM investissement_secteurs_inv is2
JOIN secteurs_inv s ON s.id = is2.secteur_id
WHERE is2.investissement_id = ?
ORDER BY is_global DESC, s.nom
`).all(req.params.id);
res.json(rows);
} catch (e) { next(e); }
});
router.put('/investissements/:id/secteurs-inv', (req, res, next) => {
try {
const { ids } = IdsSchema.parse(req.body);
const userId = req.user.id;
checkInvOwner(req.params.id, userId);
checkSectIds(ids, userId);
db.transaction(() => {
db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?').run(req.params.id);
const ins = db.prepare('INSERT INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)');
for (const id of ids) ins.run(req.params.id, id);
})();
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Helpers sync ──────────────────────────────────────────────────────────
/**
* Quand les catégories d'une plateforme changent, resynchronise tous
* les investissements actifs de cette plateforme.
* Appelé dans une transaction existante.
*/
export function syncInvestissementsCategories(platId, catIds) {
const invs = db.prepare(
"SELECT id FROM investissements WHERE plateforme_id = ? AND statut NOT IN ('rembourse','cloture')"
).all(platId);
const del = db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?');
const ins = db.prepare('INSERT OR IGNORE INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)');
for (const { id } of invs) {
del.run(id);
for (const catId of catIds) ins.run(id, catId);
}
}
export function syncInvestissementsSecteurs(platId, sectIds) {
const invs = db.prepare(
"SELECT id FROM investissements WHERE plateforme_id = ? AND statut NOT IN ('rembourse','cloture')"
).all(platId);
const del = db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?');
const ins = db.prepare('INSERT OR IGNORE INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)');
for (const { id } of invs) {
del.run(id);
for (const sectId of sectIds) ins.run(id, sectId);
}
}
export default router;
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import db from '../db/index.js';
import { signToken, requireAuth } from '../middleware/auth.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(1).optional(),
});
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
router.post('/register', (req, res, next) => {
try {
const body = RegisterSchema.parse(req.body);
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email);
if (exists) throw new HttpError(409, 'Email already registered');
// Le premier utilisateur inscrit devient automatiquement administrateur
const isFirst = db.prepare('SELECT COUNT(*) AS n FROM users').get().n === 0;
const role = isFirst ? 'admin' : 'user';
const hash = bcrypt.hashSync(body.password, 10);
const result = db
.prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
.run(body.email, hash, body.displayName || null, role);
const userId = result.lastInsertRowid;
// Auto-create le premier profil famille (= l'utilisateur lui-même)
const fullName = body.displayName || 'Mon profil';
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
const invResult = db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
).run(userId, fullName, prenom);
// Auto-créer un compte courant pour le profil principal
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(userId, 'Compte courant', 'compte_courant', invResult.lastInsertRowid);
const token = signToken({ sub: userId, email: body.email });
res.status(201).json({
token,
user: { id: userId, email: body.email, displayName: body.displayName || null, role },
});
} catch (e) { next(e); }
});
router.post('/login', (req, res, next) => {
try {
const body = LoginSchema.parse(req.body);
const user = db
.prepare('SELECT id, email, password_hash, display_name, role FROM users WHERE email = ?')
.get(body.email);
if (!user) throw new HttpError(401, 'Invalid credentials');
const ok = bcrypt.compareSync(body.password, user.password_hash);
if (!ok) throw new HttpError(401, 'Invalid credentials');
const token = signToken({ sub: user.id, email: user.email });
res.json({
token,
user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role },
});
} catch (e) { next(e); }
});
router.get('/me', requireAuth, (req, res) => {
const user = db
.prepare('SELECT id, email, display_name, role FROM users WHERE id = ?')
.get(req.user.id);
res.json({ user });
});
const UpdateMeSchema = z.object({
displayName: z.string().min(1).max(80).optional(),
email: z.string().email().optional(),
currentPassword: z.string().optional(),
newPassword: z.string().min(8).optional(),
});
router.put('/me', requireAuth, (req, res, next) => {
try {
const body = UpdateMeSchema.parse(req.body);
const current = db
.prepare('SELECT id, email, password_hash, display_name FROM users WHERE id = ?')
.get(req.user.id);
let newHash = undefined;
if (body.newPassword) {
if (!body.currentPassword)
throw new HttpError(400, 'Mot de passe actuel requis');
const ok = bcrypt.compareSync(body.currentPassword, current.password_hash);
if (!ok) throw new HttpError(401, 'Mot de passe actuel incorrect');
newHash = bcrypt.hashSync(body.newPassword, 10);
}
if (body.email && body.email !== current.email) {
const taken = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(body.email, req.user.id);
if (taken) throw new HttpError(409, 'Email déjà utilisé par un autre compte');
}
const newEmail = body.email ?? current.email;
const newDisplayName = body.displayName !== undefined ? body.displayName : current.display_name;
const newPasswordHash = newHash ?? current.password_hash;
db.prepare(
"UPDATE users SET email=?, display_name=?, password_hash=?, updated_at=datetime('now') WHERE id=?"
).run(newEmail, newDisplayName, newPasswordHash, req.user.id);
const token = newEmail !== current.email
? signToken({ sub: req.user.id, email: newEmail })
: undefined;
res.json({
user: { id: req.user.id, email: newEmail, display_name: newDisplayName },
...(token ? { token } : {}),
});
} catch (e) { next(e); }
});
export default router;
+102
View File
@@ -0,0 +1,102 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
// Monté sous requireAuth dans server.js
/* ─────────────────────────────────────────────────────────────
GET /api/categories-inv
Retourne les catégories globales (user_id IS NULL) + privées
de l'utilisateur connecté.
Chaque entrée : { id, nom, is_global, nb_plateformes, nb_investissements }
───────────────────────────────────────────────────────────────*/
router.get('/', (req, res) => {
const userId = req.user.id;
const rows = db.prepare(`
SELECT
c.id,
c.nom,
CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global,
(SELECT COUNT(*) FROM plateforme_categories_inv pc WHERE pc.categorie_id = c.id
AND pc.plateforme_id IN (
SELECT p.id FROM plateformes p
JOIN investisseurs i ON i.id = p.investisseur_id
WHERE i.user_id = ?
)
) AS nb_plateformes,
(SELECT COUNT(*) FROM investissement_categories_inv ic WHERE ic.categorie_id = c.id
AND ic.investissement_id IN (
SELECT inv.id FROM investissements inv
JOIN investisseurs i ON i.id = inv.investisseur_id
WHERE i.user_id = ?
)
) AS nb_investissements
FROM categories_inv c
WHERE c.user_id IS NULL OR c.user_id = ?
ORDER BY is_global DESC, c.nom
`).all(userId, userId, userId);
res.json(rows);
});
/* ─────────────────────────────────────────────────────────────
POST /api/categories-inv — créer une catégorie privée
───────────────────────────────────────────────────────────────*/
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const userId = req.user.id;
const dup = db.prepare(
'SELECT id FROM categories_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?)'
).get(nom.trim(), userId);
if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
const r = db.prepare(
'INSERT INTO categories_inv (nom, user_id) VALUES (?, ?)'
).run(nom.trim(), userId);
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), is_global: 0, nb_plateformes: 0, nb_investissements: 0 });
} catch (e) { next(e); }
});
/* ─────────────────────────────────────────────────────────────
PUT /api/categories-inv/:id — renommer (uniquement les privées)
───────────────────────────────────────────────────────────────*/
router.put('/:id', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const userId = req.user.id;
const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
if (row.user_id === null) throw new HttpError(403, 'Les catégories globales ne peuvent pas être modifiées.');
if (row.user_id !== userId) throw new HttpError(403, 'Cette catégorie ne vous appartient pas.');
const dup = db.prepare(
'SELECT id FROM categories_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?) AND id != ?'
).get(nom.trim(), userId, row.id);
if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
db.prepare('UPDATE categories_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
res.json({ id: row.id, nom: nom.trim(), is_global: 0 });
} catch (e) { next(e); }
});
/* ─────────────────────────────────────────────────────────────
DELETE /api/categories-inv/:id — supprimer (uniquement les privées)
───────────────────────────────────────────────────────────────*/
router.delete('/:id', (req, res, next) => {
try {
const userId = req.user.id;
const row = db.prepare('SELECT id, user_id FROM categories_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
if (row.user_id === null) throw new HttpError(403, 'Les catégories globales ne peuvent pas être supprimées.');
if (row.user_id !== userId) throw new HttpError(403, 'Cette catégorie ne vous appartient pas.');
// Retirer des associations avant suppression
db.transaction(() => {
db.prepare('DELETE FROM plateforme_categories_inv WHERE categorie_id = ?').run(row.id);
db.prepare('DELETE FROM investissement_categories_inv WHERE categorie_id = ?').run(row.id);
db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id);
})();
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+65
View File
@@ -0,0 +1,65 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const DEFAULT_CATEGORIES = [
'Crowdfunding immobilier',
'Dette privée',
'Immobilier fractionné',
'P2P Crowdlending',
];
/** Insère les catégories par défaut si l'utilisateur n'en a aucune. */
function seedDefaults(userId) {
const { n } = db
.prepare('SELECT COUNT(*) AS n FROM categories_plateforme WHERE user_id=?')
.get(userId);
if (n === 0) {
const ins = db.prepare(
'INSERT OR IGNORE INTO categories_plateforme (user_id, nom) VALUES (?,?)'
);
db.transaction((uid) => {
for (const nom of DEFAULT_CATEGORIES) ins.run(uid, nom);
})(userId);
}
}
/* GET /api/categories — liste (avec auto-seed au premier appel) */
router.get('/', (req, res) => {
seedDefaults(req.user.id);
const rows = db
.prepare('SELECT id, nom FROM categories_plateforme WHERE user_id=? ORDER BY nom')
.all(req.user.id);
res.json(rows);
});
/* POST /api/categories — créer une catégorie */
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(100) }).parse(req.body);
const r = db
.prepare('INSERT INTO categories_plateforme (user_id, nom) VALUES (?,?)')
.run(req.user.id, nom.trim());
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim() });
} catch (e) { next(e); }
});
/* DELETE /api/categories/:id — supprimer (bloqué si utilisée) */
router.delete('/:id', (req, res, next) => {
try {
const { n } = db
.prepare('SELECT COUNT(*) AS n FROM plateforme_categories WHERE categorie_id=?')
.get(req.params.id);
if (n > 0) throw new HttpError(409, `Catégorie utilisée par ${n} plateforme(s) — retirez-la d'abord.`);
const r = db
.prepare('DELETE FROM categories_plateforme WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+59
View File
@@ -0,0 +1,59 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const Schema = z.object({
nom: z.string().min(1),
type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'),
investisseur_id: z.number().int().positive().nullable().optional(),
banque: z.string().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,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal
FROM comptes c
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
WHERE c.user_id = ?
ORDER BY c.nom
`).all(req.user.id);
res.json(rows);
});
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');
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
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);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const r = db.prepare('DELETE FROM comptes WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+96
View File
@@ -0,0 +1,96 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
const router = Router();
const schema = z.object({
investisseur_id: z.number().int().positive(),
plateforme_id: z.number().int().positive(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
montant: z.number(), // positif ou négatif
notes: z.string().optional(),
});
/**
* GET /api/corrections
* ?scope=all → tous les investisseurs de l'utilisateur
* ?annee=YYYY → filtre sur l'année
*/
router.get('/', (req, res) => {
const userId = req.user.id;
const scopeAll = req.query.scope === 'all';
const annee = req.query.annee;
let where = scopeAll
? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'c.investisseur_id = ?';
const args = [scopeAll ? userId : Number(req.header('X-Investisseur-Id'))];
if (annee) {
where += ' AND substr(c.date,1,4) = ?';
args.push(annee);
}
const rows = db.prepare(`
SELECT c.id, c.investisseur_id, c.plateforme_id,
c.date, c.montant, c.notes, c.created_at,
p.nom AS plateforme_nom,
p.fiscalite,
inv.nom AS investisseur_nom
FROM corrections_solde c
JOIN plateformes p ON p.id = c.plateforme_id
JOIN investisseurs inv ON inv.id = c.investisseur_id
WHERE ${where}
ORDER BY c.date DESC, c.created_at DESC
`).all(...args);
res.json(rows);
});
/**
* POST /api/corrections
*/
router.post('/', (req, res) => {
const userId = req.user.id;
const data = schema.parse(req.body);
// Vérifier que la plateforme appartient à l'utilisateur
const plat = db.prepare('SELECT id FROM plateformes WHERE id = ? AND user_id = ?').get(data.plateforme_id, userId);
if (!plat) return res.status(403).json({ error: 'Plateforme inconnue ou non autorisée' });
// Vérifier que l'investisseur appartient à l'utilisateur
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(data.investisseur_id, userId);
if (!inv) return res.status(403).json({ error: 'Investisseur inconnu ou non autorisé' });
const stmt = db.prepare(`
INSERT INTO corrections_solde (investisseur_id, plateforme_id, date, montant, notes)
VALUES (?, ?, ?, ?, ?)
`);
const result = stmt.run(data.investisseur_id, data.plateforme_id, data.date, data.montant, data.notes ?? null);
res.status(201).json({ id: result.lastInsertRowid });
});
/**
* DELETE /api/corrections/:id
*/
router.delete('/:id', (req, res) => {
const userId = req.user.id;
const id = Number(req.params.id);
// Vérifier que la correction appartient à un investisseur de cet utilisateur
const corr = db.prepare(`
SELECT c.id FROM corrections_solde c
JOIN investisseurs inv ON inv.id = c.investisseur_id
WHERE c.id = ? AND inv.user_id = ?
`).get(id, userId);
if (!corr) return res.status(404).json({ error: 'Correction introuvable' });
db.prepare('DELETE FROM corrections_solde WHERE id = ?').run(id);
res.status(204).end();
});
export default router;
+884
View File
@@ -0,0 +1,884 @@
import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
/**
* GET /api/dashboard
*
* ?scope=all → agrège tous les investisseurs de l'utilisateur
* (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id
*/
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
/* ── Résolution de l'investisseur cible ─────────────────────── */
let invWhere, invParams, invId;
if (scopeAll) {
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
invId = Number(raw);
if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invWhere = 'investisseur_id = ?';
invParams = [invId];
}
/* ── Cash global ─────────────────────────────────────────────── */
const cash = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type='depot' THEN montant END),0) AS total_depots,
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END),0) AS total_retraits
FROM depots_retraits WHERE ${invWhere}
`).get(...invParams);
/* ── Cash par plateforme ─────────────────────────────────────── */
const cashByPlatform = db.prepare(`
SELECT p.id AS plateforme_id, p.nom,
plat_inv.nom AS detenteur_nom,
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant END),0) AS depots,
COALESCE(SUM(CASE WHEN dr.type='retrait' THEN dr.montant END),0) AS retraits,
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant
WHEN dr.type='retrait' THEN -dr.montant END),0) AS solde_net
FROM plateformes p
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
LEFT JOIN depots_retraits dr ON dr.plateforme_id = p.id AND ${invWhere.replace('investisseur_id', 'dr.investisseur_id')}
WHERE p.user_id = ?
GROUP BY p.id, p.nom, plat_inv.nom
ORDER BY p.nom
`).all(...invParams, userId);
/* ── Solde porte-monnaie par plateforme ──────────────────────── */
const drManuelPerPlat = db.prepare(`
SELECT plateforme_id,
COALESCE(SUM(CASE WHEN type='depot' THEN montant ELSE 0 END), 0) AS depots,
COALESCE(SUM(CASE WHEN type='retrait' AND COALESCE(source,'') != 'auto_remboursement'
THEN montant ELSE 0 END), 0) AS retraits_manuels
FROM depots_retraits
WHERE ${invWhere}
GROUP BY plateforme_id
`).all(...invParams);
const rembWalletPerPlat = db.prepare(`
SELECT i.plateforme_id,
COALESCE(SUM(
CASE p.fiscalite
WHEN 'flat_tax' THEN r.net_recu
-- Plateformes hors France : pas de PFU prélevé à la source.
-- interets_bruts est déjà net de la retenue locale si applicable.
ELSE r.capital + r.cashback + r.interets_bruts
END
), 0) AS remb_wallet
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND r.type = 'normal'
AND r.methode_remboursement = 'portefeuille'
GROUP BY i.plateforme_id
`).all(...invParams);
const bonusWalletPerPlat = db.prepare(`
SELECT bonus_plateforme_id AS plateforme_id,
COALESCE(SUM(cashback), 0) AS bonus_wallet
FROM remboursements
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
AND type IN ('bonus_parrainage', 'bonus_plateforme')
GROUP BY bonus_plateforme_id
`).all(...invParams);
const capitalInvestiPerPlat = db.prepare(`
SELECT i.plateforme_id,
COALESCE(SUM(
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
), 0) AS capital_investi
FROM investissements i
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
GROUP BY i.plateforme_id
`).all(...invParams);
// Corrections de solde (micro-ecarts fiscaux sur plateformes flat_tax)
const correctionsPerPlat = db.prepare(`
SELECT plateforme_id,
COALESCE(SUM(montant), 0) AS total_correction
FROM corrections_solde
WHERE ${invWhere}
GROUP BY plateforme_id
`).all(...invParams);
// Fusion en une map plateforme_id → solde_portefeuille
const walletMap = {};
for (const r of drManuelPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.depots - r.retraits_manuels;
for (const r of rembWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.remb_wallet;
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
const cashByPlatformEnriched = cashByPlatform.map(p => ({
...p,
solde_portefeuille: Math.round((walletMap[p.plateforme_id] || 0) * 100) / 100,
}));
const solde_portefeuille_total = Math.round(
cashByPlatformEnriched.reduce((s, p) => s + p.solde_portefeuille, 0) * 100
) / 100;
/* ── Portfolio ───────────────────────────────────────────────── */
const portfolio = db.prepare(`
SELECT
COUNT(*) AS nb_projets,
COALESCE(SUM(i.montant_investi),0) AS total_investi,
COALESCE(SUM(CASE WHEN i.statut='en_cours' THEN
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
END), 0) AS encours,
COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END),0) AS rembourse,
COALESCE(SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
END), 0) AS en_defaut
FROM investissements i WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
`).get(...invParams);
/* ── Interets ────────────────────────────────────────────────── */
const interets = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts),0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux),0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire,
COALESCE(SUM(r.net_recu),0) AS net_recu
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
`).get(...invParams);
/* ── Interets par annee ──────────────────────────────────────── */
const interetsParAnnee = scopeAll
? db.prepare(`
SELECT 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 v_interets_annuels
WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
GROUP BY annee ORDER BY annee DESC
`).all(userId)
: db.prepare(`
SELECT * FROM v_interets_annuels WHERE investisseur_id = ? ORDER BY annee DESC
`).all(invId);
/* ── Prochaines echeances ────────────────────────────────────── */
const upcoming = db.prepare(`
SELECT s.*, i.nom_projet, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
(SELECT r.date_remb FROM remboursements r
WHERE r.investissement_id = s.investissement_id
AND r.date_remb = s.date_prevue
LIMIT 1) AS remb_date_exact,
(SELECT r.date_remb FROM remboursements r
WHERE r.investissement_id = s.investissement_id
AND strftime('%Y-%m', r.date_remb) = strftime('%Y-%m', s.date_prevue)
AND r.date_remb != s.date_prevue
LIMIT 1) AS remb_date_mois
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND s.date_prevue >= date('now','start of month')
AND s.date_prevue < date('now','start of month','+1 month')
ORDER BY s.date_prevue
LIMIT 50
`).all(...invParams);
/* ── En retard / procedure ───────────────────────────────────── */
const enDefaut = db.prepare(`
SELECT i.*, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND i.statut IN ('en_retard','procedure')
ORDER BY i.date_souscription DESC
`).all(...invParams);
res.json({ cash, cashByPlatform: cashByPlatformEnriched, solde_portefeuille_total, portfolio, interets, interetsParAnnee, upcoming, enDefaut });
});
/**
* GET /api/dashboard/solde-historique
*
* Retourne le solde porte-monnaie calculé pour une plateforme à une date donnée.
* Utilise la même formule que solde_portefeuille mais filtrée par date <= :date.
*
* Query params : plateforme_id (requis), date YYYY-MM-DD (requis)
*/
router.get('/solde-historique', (req, res) => {
const { plateforme_id, date } = req.query;
if (!plateforme_id || !date) {
return res.status(400).json({ error: 'plateforme_id et date sont requis' });
}
const userId = req.user.id;
const platId = Number(plateforme_id);
// Vérifier que la plateforme appartient à l'utilisateur
const plat = db.prepare('SELECT id FROM plateformes WHERE id = ? AND user_id = ?').get(platId, userId);
if (!plat) return res.status(403).json({ error: 'Plateforme non trouvée' });
/* ── Résolution du scope investisseur (même logique que GET /) ── */
let invWhere, invParams;
const raw = req.header('X-Investisseur-Id');
const invId = Number(raw);
if (invId) {
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) return res.status(403).json({ error: 'Investisseur non trouvé' });
invWhere = 'investisseur_id = ?';
invParams = [invId];
} else {
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [userId];
}
/* ── 1. Dépôts retraits manuels (jusqu'à :date) ─────────────── */
const dr = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type='depot' THEN montant ELSE 0 END), 0) AS depots,
COALESCE(SUM(CASE WHEN type='retrait'
AND COALESCE(source,'') != 'auto_remboursement'
THEN montant ELSE 0 END), 0) AS retraits_manuels
FROM depots_retraits
WHERE plateforme_id = ? AND ${invWhere} AND date_operation <= ?
`).get(platId, ...invParams, date);
/* ── 2. Remboursements vers porte-monnaie (jusqu'à :date) ──────── */
const remb = db.prepare(`
SELECT COALESCE(SUM(
CASE p.fiscalite
WHEN 'flat_tax' THEN r.net_recu
ELSE r.capital + r.cashback + r.interets_bruts
END
), 0) AS remb_wallet
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND r.type = 'normal' AND r.methode_remboursement = 'portefeuille'
AND r.date_remb <= ?
`).get(platId, ...invParams, date);
/* ── 3. Bonus porte-monnaie (jusqu'à :date) ────────────────────── */
const bonus = db.prepare(`
SELECT COALESCE(SUM(cashback), 0) AS bonus_wallet
FROM remboursements
WHERE bonus_plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
AND type IN ('bonus_parrainage', 'bonus_plateforme')
AND date_remb <= ?
`).get(platId, ...invParams, date);
/* ── 4. Capital investi (souscriptions + réinvestissements jusqu'à :date) ── */
const capital = db.prepare(`
SELECT COALESCE(SUM(
i.montant_investi
+ COALESCE((
SELECT SUM(rv.montant)
FROM reinvestissements rv
WHERE rv.investissement_id = i.id
AND rv.date_reinvestissement <= ?
), 0)
), 0) AS capital_investi
FROM investissements i
WHERE i.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND i.date_souscription <= ?
`).get(date, platId, ...invParams, date);
/* ── 5. Corrections de solde (jusqu'à :date) ───────────────────── */
const corrections = db.prepare(`
SELECT COALESCE(SUM(montant), 0) AS total_correction
FROM corrections_solde
WHERE plateforme_id = ? AND ${invWhere} AND date <= ?
`).get(platId, ...invParams, date);
const solde = Math.round((
dr.depots
- dr.retraits_manuels
+ remb.remb_wallet
+ bonus.bonus_wallet
- capital.capital_investi
+ corrections.total_correction
) * 100) / 100;
res.json({ solde });
});
/**
* GET /api/dashboard/interets-mensuels
*
* Retourne les intérêts réels (remboursements) et projetés (simul_remboursements)
* groupés par mois YYYY-MM pour l'année demandée.
*
* Query params : annee (int, défaut = année courante), scope=all (optionnel)
*/
router.get('/interets-mensuels', (req, res) => {
const annee = parseInt(req.query.annee) || new Date().getFullYear();
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
const anneeStr = String(annee);
let invWhere, invParams, invId;
if (scopeAll) {
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
invId = Number(raw);
if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invWhere = 'investisseur_id = ?';
invParams = [invId];
}
/* ── Intérêts réels groupés par mois ─────────────────────────── */
// Inclut les remboursements normaux (via investissement) ET les bonus (via bonus_investisseur_id)
let rembourses;
if (scopeAll) {
rembourses = db.prepare(`
SELECT
strftime('%Y-%m', r.date_remb) AS mois,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets,
COALESCE(SUM(r.capital), 0) AS capital,
COALESCE(SUM(r.cashback), 0) AS cashback
FROM remboursements r
LEFT JOIN investissements inv ON inv.id = r.investissement_id
LEFT JOIN investisseurs own ON own.id = inv.investisseur_id
LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id
WHERE strftime('%Y', r.date_remb) = ?
AND (
(r.investissement_id IS NOT NULL AND own.user_id = ?)
OR (r.bonus_investisseur_id IS NOT NULL AND bonus_own.user_id = ?)
)
GROUP BY mois
ORDER BY mois
`).all(anneeStr, userId, userId);
} else {
rembourses = db.prepare(`
SELECT
strftime('%Y-%m', r.date_remb) AS mois,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets,
COALESCE(SUM(r.capital), 0) AS capital,
COALESCE(SUM(r.cashback), 0) AS cashback
FROM remboursements r
LEFT JOIN investissements inv ON inv.id = r.investissement_id
LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id
WHERE strftime('%Y', r.date_remb) = ?
AND (
(r.investissement_id IS NOT NULL AND inv.investisseur_id = ?)
OR (r.bonus_investisseur_id IS NOT NULL AND r.bonus_investisseur_id = ?)
)
GROUP BY mois
ORDER BY mois
`).all(anneeStr, invId, invId);
}
/* ── Projections groupées par mois ───────────────────────────── */
// Exclure les investissements qui ont déjà un remboursement réel dans le même mois
// (évite le double-comptage pour le mois en cours)
const projections = db.prepare(`
SELECT
strftime('%Y-%m', s.date_prevue) AS mois,
COALESCE(SUM(s.interets_prevus), 0) AS interets_prevus,
COALESCE(SUM(s.capital_prevu), 0) AS capital_prevu
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND strftime('%Y', s.date_prevue) = ?
AND NOT EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = s.investissement_id
AND strftime('%Y-%m', r.date_remb) = strftime('%Y-%m', s.date_prevue)
)
GROUP BY mois
ORDER BY mois
`).all(...invParams, anneeStr);
/* ── Années disponibles (remboursements + projections) ─── */
const annees = db.prepare(`
SELECT DISTINCT CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
UNION
SELECT DISTINCT CAST(strftime('%Y', s.date_prevue) AS INTEGER) AS annee
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND CAST(strftime('%Y', s.date_prevue) AS INTEGER) <= CAST(strftime('%Y', 'now') AS INTEGER) + 10
ORDER BY annee
`).all(...invParams, ...invParams).map(r => r.annee);
res.json({ rembourses, projections, annees });
});
/**
* GET /api/dashboard/interets-annuels
*
* Retourne les intérêts réels et projetés groupés par ANNÉE (toutes années).
* Les projections excluent les échéances déjà passées (>= début du mois courant).
*
* Query params : scope=all (optionnel)
*/
router.get('/interets-annuels', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invWhere, invParams, invId;
if (scopeAll) {
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
invId = Number(raw);
if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invWhere = 'investisseur_id = ?';
invParams = [invId];
}
/* ── Intérêts réels groupés par année ────────────────────────── */
let rembourses;
if (scopeAll) {
rembourses = db.prepare(`
SELECT
CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets,
COALESCE(SUM(r.capital), 0) AS capital,
COALESCE(SUM(r.cashback), 0) AS cashback
FROM remboursements r
LEFT JOIN investissements inv ON inv.id = r.investissement_id
LEFT JOIN investisseurs own ON own.id = inv.investisseur_id
LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id
WHERE (
(r.investissement_id IS NOT NULL AND own.user_id = ?)
OR (r.bonus_investisseur_id IS NOT NULL AND bonus_own.user_id = ?)
)
GROUP BY annee
ORDER BY annee
`).all(userId, userId);
} else {
rembourses = db.prepare(`
SELECT
CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets,
COALESCE(SUM(r.capital), 0) AS capital,
COALESCE(SUM(r.cashback), 0) AS cashback
FROM remboursements r
LEFT JOIN investissements inv ON inv.id = r.investissement_id
LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id
WHERE (
(r.investissement_id IS NOT NULL AND inv.investisseur_id = ?)
OR (r.bonus_investisseur_id IS NOT NULL AND r.bonus_investisseur_id = ?)
)
GROUP BY annee
ORDER BY annee
`).all(invId, invId);
}
/* ── Projections groupées par année (uniquement futures/en cours) */
const projections = db.prepare(`
SELECT
CAST(strftime('%Y', s.date_prevue) AS INTEGER) AS annee,
COALESCE(SUM(s.interets_prevus), 0) AS interets_prevus,
COALESCE(SUM(s.capital_prevu), 0) AS capital_prevu
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND s.date_prevue >= date('now', 'start of month')
GROUP BY annee
ORDER BY annee
`).all(...invParams);
/* ── Années disponibles (remboursements + projections) ───────── */
const annees = db.prepare(`
SELECT DISTINCT CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
UNION
SELECT DISTINCT CAST(strftime('%Y', s.date_prevue) AS INTEGER) AS annee
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND CAST(strftime('%Y', s.date_prevue) AS INTEGER) <= CAST(strftime('%Y', 'now') AS INTEGER) + 10
ORDER BY annee
`).all(...invParams, ...invParams).map(r => r.annee);
/* ── Capital souscrit par année (pour KPI "Capital investi" filtré) ── */
const capitalParAnnee = db.prepare(`
SELECT
CAST(strftime('%Y', i.date_souscription) AS INTEGER) AS annee,
COALESCE(SUM(
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
), 0) AS capital_souscrit
FROM investissements i
WHERE ${invWhere}
GROUP BY annee
ORDER BY annee
`).all(...invParams);
res.json({ rembourses, projections, annees, capitalParAnnee });
});
/**
* GET /api/dashboard/interets-par-plateforme
*
* Retourne les intérêts réels + projetés groupés par plateforme et par mois
* pour l'année demandée, ainsi que le capital investi estimé chaque mois.
*
* Query params : annee (int, défaut = année courante), scope=all (optionnel)
*/
router.get('/interets-par-plateforme', (req, res) => {
const annee = parseInt(req.query.annee) || new Date().getFullYear();
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
const anneeStr = String(annee);
let invWhere, invParams, invId;
if (scopeAll) {
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
invId = Number(raw);
if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invWhere = 'investisseur_id = ?';
invParams = [invId];
}
/* ── Intérêts réels par plateforme et par mois ───────────────── */
const remboursesByPlat = db.prepare(`
SELECT
p.id AS plateforme_id,
p.nom AS plateforme_nom,
strftime('%Y-%m', r.date_remb) AS mois,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets,
COALESCE(SUM(r.cashback), 0) AS cashback,
COALESCE(SUM(r.capital), 0) AS capital
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND strftime('%Y', r.date_remb) = ?
AND r.type = 'normal'
GROUP BY p.id, p.nom, mois
ORDER BY p.nom, mois
`).all(...invParams, anneeStr);
/* ── Cashback bonus (parrainage / plateforme) par plateforme et par mois ── */
const bonusCashbackByPlat = db.prepare(`
SELECT
r.bonus_plateforme_id AS plateforme_id,
p.nom AS plateforme_nom,
strftime('%Y-%m', r.date_remb) AS mois,
COALESCE(SUM(r.cashback), 0) AS cashback
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
AND r.type IN ('bonus_parrainage', 'bonus_plateforme')
AND strftime('%Y', r.date_remb) = ?
GROUP BY r.bonus_plateforme_id, p.nom, mois
ORDER BY p.nom, mois
`).all(...invParams, anneeStr);
/* ── Projections par plateforme et par mois ──────────────────── */
// Exclure les investissements qui ont déjà un remboursement réel dans le même mois
const projectionsByPlat = db.prepare(`
SELECT
p.id AS plateforme_id,
p.nom AS plateforme_nom,
strftime('%Y-%m', s.date_prevue) AS mois,
COALESCE(SUM(s.interets_prevus), 0) AS interets_prevus,
COALESCE(SUM(s.capital_prevu), 0) AS capital_prevu
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND strftime('%Y', s.date_prevue) = ?
AND NOT EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = s.investissement_id
AND strftime('%Y-%m', r.date_remb) = strftime('%Y-%m', s.date_prevue)
)
GROUP BY p.id, p.nom, mois
ORDER BY p.nom, mois
`).all(...invParams, anneeStr);
/* ── Capital investi mensuel ─────────────────────────────────── */
// Récupérer tous les investissements avec leurs dates pour calculer
// le capital restant dû chaque mois (montant_investi + reinvests capital_remboursé_cumulé)
const invs = db.prepare(`
SELECT
i.id,
i.montant_investi,
i.date_souscription,
i.date_cible,
i.statut,
(SELECT MAX(r.date_remb) FROM remboursements r WHERE r.investissement_id = i.id) AS last_remb_date
FROM investissements i
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
`).all(...invParams);
const invIds = invs.map(i => i.id);
const placeholder = invIds.map(() => '?').join(',');
// Remboursements de capital par investissement avec leur date (tous types normaux)
const capitalRembRows = invIds.length ? db.prepare(`
SELECT investissement_id, date_remb, SUM(capital) AS capital_remb
FROM remboursements
WHERE investissement_id IN (${placeholder})
AND type = 'normal'
GROUP BY investissement_id, date_remb
`).all(...invIds) : [];
// Réinvestissements par investissement avec leur date
const reinvestRows = invIds.length ? db.prepare(`
SELECT investissement_id, date_reinvestissement, SUM(montant) AS montant
FROM reinvestissements
WHERE investissement_id IN (${placeholder})
GROUP BY investissement_id, date_reinvestissement
`).all(...invIds) : [];
// Capital projeté par investissement (simul_remboursements) — pour les mois futurs
const capitalSimulRows = invIds.length ? db.prepare(`
SELECT investissement_id, date_prevue, SUM(capital_prevu) AS capital_prevu
FROM simul_remboursements
WHERE investissement_id IN (${placeholder})
GROUP BY investissement_id, date_prevue
`).all(...invIds) : [];
// Map : inv_id → [{date, capital}]
const capitalRembMap = {};
for (const r of capitalRembRows) {
if (!capitalRembMap[r.investissement_id]) capitalRembMap[r.investissement_id] = [];
capitalRembMap[r.investissement_id].push({ date: r.date_remb, capital: r.capital_remb });
}
// Map : inv_id → [{date, montant}]
const reinvestMap = {};
for (const r of reinvestRows) {
if (!reinvestMap[r.investissement_id]) reinvestMap[r.investissement_id] = [];
reinvestMap[r.investissement_id].push({ date: r.date_reinvestissement, montant: r.montant });
}
// Map : inv_id → [{date, capital}]
const capitalSimulMap = {};
for (const r of capitalSimulRows) {
if (!capitalSimulMap[r.investissement_id]) capitalSimulMap[r.investissement_id] = [];
capitalSimulMap[r.investissement_id].push({ date: r.date_prevue, capital: r.capital_prevu });
}
// Date du jour au format YYYY-MM-DD (locale) pour distinguer passé/futur
const _today = new Date();
const todayStr = `${_today.getFullYear()}-${String(_today.getMonth() + 1).padStart(2, '0')}-${String(_today.getDate()).padStart(2, '0')}`;
const capitalMensuel = [];
for (let m = 1; m <= 12; m++) {
const moisStr = `${anneeStr}-${String(m).padStart(2, '0')}`;
const firstDay = `${moisStr}-01`;
// Dernier jour du mois (calcul local pour éviter le décalage UTC)
const _d = new Date(annee, m, 0);
const lastDay = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, '0')}-${String(_d.getDate()).padStart(2, '0')}`;
let capital = 0;
let enDefaut = 0;
for (const inv of invs) {
if (inv.date_souscription > lastDay) continue; // pas encore débuté
// Capital réellement remboursé jusqu'à la fin de ce mois
const rembs = capitalRembMap[inv.id] ?? [];
const capitalRembCumul = rembs
.filter(r => r.date <= lastDay)
.reduce((s, r) => s + r.capital, 0);
// Capital projeté pour les échéances futures (après le dernier remb réel et après aujourd'hui)
const lastRembDate = inv.last_remb_date ?? '0000-00-00';
const capitalSimulCumul = (capitalSimulMap[inv.id] ?? [])
.filter(r => r.date > todayStr && r.date > lastRembDate && r.date <= lastDay)
.reduce((s, r) => s + r.capital, 0);
const reinvestsCumul = (reinvestMap[inv.id] ?? [])
.filter(r => r.date <= lastDay)
.reduce((s, r) => s + r.montant, 0);
const capitalRestant = Math.max(0, inv.montant_investi + reinvestsCumul - capitalRembCumul - capitalSimulCumul);
const actif = ['en_cours', 'en_retard', 'procedure'].includes(inv.statut);
if (actif) {
capital += capitalRestant;
if (['en_retard', 'procedure'].includes(inv.statut)) enDefaut += capitalRestant;
} else {
// Remboursé/clôturé : était-il encore actif ce mois-ci ?
// Priorité : date_cible → dernier remboursement réel → exclure
const fin = inv.date_cible ?? inv.last_remb_date ?? null;
if (fin && fin >= firstDay) {
capital += capitalRestant;
}
}
}
capitalMensuel.push({ mois: moisStr, capital: Math.round(capital * 100) / 100, en_defaut: Math.round(enDefaut * 100) / 100 });
}
/* ── Noms des détenteurs par plateforme ─────────────────────── */
const detenteurRows = db.prepare(`
SELECT p.id AS plateforme_id, inv.nom AS detenteur_nom
FROM plateformes p
LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id
WHERE p.user_id = ?
`).all(userId);
const detenteurMap = {};
for (const d of detenteurRows) detenteurMap[d.plateforme_id] = d.detenteur_nom;
/* ── Construire la réponse par plateforme ────────────────────── */
// Indexer les rembs et projections par plateforme_id puis mois
const rembMap = {};
for (const r of remboursesByPlat) {
if (!rembMap[r.plateforme_id]) rembMap[r.plateforme_id] = { id: r.plateforme_id, nom: r.plateforme_nom, detenteur_nom: detenteurMap[r.plateforme_id] ?? null, rembourses: {}, projections: {} };
rembMap[r.plateforme_id].rembourses[r.mois] = { interets_bruts: r.interets_bruts, interets_nets: r.interets_nets, cashback: r.cashback, capital: r.capital };
}
// Ajouter le cashback bonus (parrainage/plateforme) aux remboursements par mois
for (const b of bonusCashbackByPlat) {
if (!rembMap[b.plateforme_id]) rembMap[b.plateforme_id] = { id: b.plateforme_id, nom: b.plateforme_nom, detenteur_nom: detenteurMap[b.plateforme_id] ?? null, rembourses: {}, projections: {} };
const entry = rembMap[b.plateforme_id].rembourses[b.mois];
if (entry) {
entry.cashback = (entry.cashback ?? 0) + b.cashback;
} else {
rembMap[b.plateforme_id].rembourses[b.mois] = { interets_bruts: 0, interets_nets: 0, cashback: b.cashback, capital: 0 };
}
}
for (const p of projectionsByPlat) {
if (!rembMap[p.plateforme_id]) rembMap[p.plateforme_id] = { id: p.plateforme_id, nom: p.plateforme_nom, detenteur_nom: detenteurMap[p.plateforme_id] ?? null, rembourses: {}, projections: {} };
rembMap[p.plateforme_id].projections[p.mois] = { interets_prevus: p.interets_prevus, capital_prevu: p.capital_prevu };
}
const plateformes = Object.values(rembMap).sort((a, b) => a.nom.localeCompare(b.nom));
res.json({ annee, plateformes, capitalMensuel });
});
/**
* GET /api/dashboard/detail-cellule
*
* Retourne le détail ligne par ligne d'une cellule du tableau mensuel :
* - recus : remboursements réels pour plateforme_id + YYYY-MM
* - projetes : simul_remboursements pour plateforme_id + YYYY-MM
* (uniquement les projections sans remboursement réel dans ce mois)
*
* Query params : plateforme_id (int), annee (4 chiffres), mois (01-12), scope=all (opt)
*/
router.get('/detail-cellule', (req, res) => {
const { plateforme_id, annee, mois } = req.query;
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
if (!annee || !mois)
return res.status(400).json({ error: 'annee et mois sont requis' });
const moisStr = `${annee}-${String(mois).padStart(2, '0')}`;
const platId = plateforme_id ? Number(plateforme_id) : null;
const platFilter = platId ? 'AND i.plateforme_id = ?' : '';
let invWhere, invParams;
if (scopeAll) {
invWhere = 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const invId = Number(raw);
if (!invId) return res.status(400).json({ error: 'Missing X-Investisseur-Id' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id=? AND user_id=?').get(invId, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not owned by user' });
invWhere = 'i.investisseur_id = ?';
invParams = [invId];
}
const recusParams = platId ? [...invParams, platId, moisStr] : [...invParams, moisStr];
const projetesParams = platId ? [...invParams, platId, moisStr, moisStr] : [...invParams, moisStr, moisStr];
/* ── Remboursements reçus ─────────────────────────────────── */
const recus = db.prepare(`
SELECT
r.id, r.investissement_id, r.date_remb,
r.capital, r.cashback,
r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
r.interets_nets, r.net_recu,
i.nom_projet, i.plateforme_id,
p.nom AS plateforme_nom,
inv.nom AS detenteur_nom
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
LEFT JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invWhere}
${platFilter}
AND strftime('%Y-%m', r.date_remb) = ?
AND r.type = 'normal'
ORDER BY p.nom, r.date_remb, i.nom_projet
`).all(...recusParams);
/* ── Projections (sans remb réel dans le même mois) ─────── */
const projetes = db.prepare(`
SELECT
s.id, s.investissement_id, s.date_prevue,
s.capital_prevu, s.interets_prevus, s.total_prevu,
s.numero_echeance,
i.nom_projet, i.plateforme_id,
p.nom AS plateforme_nom,
inv.nom AS detenteur_nom
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
LEFT JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invWhere}
${platFilter}
AND strftime('%Y-%m', s.date_prevue) = ?
AND NOT EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = s.investissement_id
AND strftime('%Y-%m', r.date_remb) = ?
)
ORDER BY p.nom, s.date_prevue, i.nom_projet
`).all(...projetesParams);
res.json({ recus, projetes });
});
export default router;
+118
View File
@@ -0,0 +1,118 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
const router = Router();
const Schema = z.object({
investisseur_id: z.number().int().positive().optional(),
plateforme_id: z.number().int().positive(),
date_operation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
type: z.enum(['depot', 'retrait']),
montant: z.number().nonnegative(),
libelle: z.string().optional(),
reference: z.string().optional(),
notes: z.string().optional(),
});
/** Résout l'investisseur_id : body en priorité (validé), sinon header */
function resolveInvestisseurId(req, bodyInvestisseurId) {
if (!bodyInvestisseurId) return req.investisseur.id;
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(bodyInvestisseurId, req.user.id);
if (!row) throw new HttpError(403, 'Investisseur non autorisé');
return bodyInvestisseurId;
}
/**
* GET /api/depots-retraits
*
* ?scope=all → agrège tous les investisseurs de l'utilisateur (vue "Famille")
* (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id
*/
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, args;
if (scopeAll) {
invCond = 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
args = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'dr.investisseur_id = ?';
args = [id];
}
const { from, to, type, plateforme_id } = req.query;
const conds = [invCond];
if (from) { conds.push('dr.date_operation >= ?'); args.push(from); }
if (to) { conds.push('dr.date_operation <= ?'); args.push(to); }
if (type) { conds.push('dr.type = ?'); args.push(type); }
if (plateforme_id) { conds.push('dr.plateforme_id = ?'); args.push(Number(plateforme_id)); }
const rows = db.prepare(`
SELECT dr.*, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
WHERE ${conds.join(' AND ')}
ORDER BY dr.date_operation DESC, dr.id DESC
`).all(...args);
res.json(rows);
});
router.post('/', requireInvestisseur, (req, res, next) => {
try {
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
const r = db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes)
VALUES (?,?,?,?,?,?,?, 'manuel', ?)
`).run(
investisseurId, body.plateforme_id, body.date_operation, body.type,
body.montant, body.libelle || null, body.reference || null, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
router.put('/:id', requireInvestisseur, (req, res, next) => {
try {
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
const r = db.prepare(`
UPDATE depots_retraits
SET investisseur_id=?, plateforme_id=?, date_operation=?, type=?, montant=?,
libelle=?, reference=?, notes=?, updated_at=datetime('now')
WHERE id=? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id=?)
`).run(
investisseurId, body.plateforme_id, body.date_operation, body.type, body.montant,
body.libelle || null, body.reference || null, body.notes || null,
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); }
});
router.delete('/:id', requireInvestisseur, (req, res, next) => {
try {
const r = db.prepare(`
DELETE FROM depots_retraits
WHERE id=? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id=?)
`).run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+135
View File
@@ -0,0 +1,135 @@
import { Router } from 'express';
import multer from 'multer';
import xlsx from 'xlsx';
import path from 'node:path';
import fs from 'node:fs';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve('./uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const upload = multer({ dest: UPLOAD_DIR, limits: { fileSize: 5 * 1024 * 1024 } });
const router = Router();
/* ── GET /api/garanties ──────────────────────────────────────── */
router.get('/', (req, res) => {
const rows = db.prepare(
'SELECT * FROM garantie_types WHERE user_id = ? ORDER BY ordre, id'
).all(req.user.id);
res.json(rows);
});
/* ── POST /api/garanties ─────────────────────────────────────── */
router.post('/', (req, res, next) => {
try {
const { libelle, description, ordre } = req.body || {};
if (!libelle?.trim()) throw new HttpError(400, 'libelle est requis');
const r = db.prepare(
'INSERT INTO garantie_types (user_id, libelle, description, ordre) VALUES (?,?,?,?)'
).run(req.user.id, libelle.trim(), description?.trim() || null, Number(ordre ?? 0));
res.status(201).json(db.prepare('SELECT * FROM garantie_types WHERE id = ?').get(r.lastInsertRowid));
} catch (e) { next(e); }
});
/* ── PUT /api/garanties/:id ──────────────────────────────────── */
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM garantie_types WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!row) throw new HttpError(404, 'Garantie introuvable');
const { libelle, description, ordre } = req.body || {};
if (!libelle?.trim()) throw new HttpError(400, 'libelle est requis');
db.prepare(
'UPDATE garantie_types SET libelle=?, description=?, ordre=? WHERE id=?'
).run(libelle.trim(), description?.trim() || null, Number(ordre ?? 0), req.params.id);
res.json(db.prepare('SELECT * FROM garantie_types WHERE id = ?').get(req.params.id));
} catch (e) { next(e); }
});
/* ── DELETE /api/garanties/:id ───────────────────────────────── */
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM garantie_types WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!row) throw new HttpError(404, 'Garantie introuvable');
db.prepare('DELETE FROM garantie_types WHERE id = ?').run(req.params.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
/* ── POST /api/garanties/import ─────────────────────────────────
Accepte un fichier .xlsx / .csv / .json
Colonnes reconnues : libelle (obligatoire), description, ordre
Comportement : upsert sur libelle (insensible à la casse)
─────────────────────────────────────────────────────────────── */
router.post('/import', upload.single('file'), (req, res, next) => {
try {
if (!req.file) throw new HttpError(400, 'Aucun fichier reçu');
const ext = path.extname(req.file.originalname).toLowerCase();
let rows;
if (ext === '.json') {
const content = fs.readFileSync(req.file.path, 'utf8');
let parsed;
try { parsed = JSON.parse(content); } catch {
throw new HttpError(400, 'Fichier JSON invalide');
}
rows = Array.isArray(parsed) ? parsed
: Array.isArray(parsed?.garanties) ? parsed.garanties
: null;
if (!rows) throw new HttpError(400, 'Le JSON doit être un tableau ou contenir une clé "garanties"');
} else {
const wb = xlsx.readFile(req.file.path, { cellDates: true });
const ws = wb.Sheets[wb.SheetNames[0]];
rows = xlsx.utils.sheet_to_json(ws, { defval: null, raw: false });
}
try { fs.unlinkSync(req.file.path); } catch { /* noop */ }
if (!rows.length) return res.json({ inserted: 0, updated: 0, skipped: 0, errors: [] });
// Normalise les clés (insensible à la casse et aux espaces)
const norm = (obj) => {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k.toLowerCase().trim()] = v;
return out;
};
let inserted = 0, updated = 0, skipped = 0;
const errors = [];
const tx = db.transaction(() => {
for (let i = 0; i < rows.length; i++) {
const r = norm(rows[i]);
const libelle = String(r.libelle ?? r['libellé'] ?? r.label ?? '').trim();
if (!libelle) { skipped++; errors.push({ row: i + 2, error: 'libelle vide — ligne ignorée' }); continue; }
const description = String(r.description ?? r.desc ?? '').trim() || null;
const ordre = parseInt(r.ordre ?? r.order ?? 0, 10) || 0;
const existing = db.prepare(
'SELECT id FROM garantie_types WHERE user_id = ? AND LOWER(libelle) = LOWER(?)'
).get(req.user.id, libelle);
if (existing) {
db.prepare(
'UPDATE garantie_types SET description=?, ordre=? WHERE id=?'
).run(description, ordre, existing.id);
updated++;
} else {
db.prepare(
'INSERT INTO garantie_types (user_id, libelle, description, ordre) VALUES (?,?,?,?)'
).run(req.user.id, libelle, description, ordre);
inserted++;
}
}
});
tx();
res.json({ inserted, updated, skipped, total: rows.length, errors: errors.slice(0, 20) });
} catch (e) { next(e); }
});
export default router;
+315
View File
@@ -0,0 +1,315 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import db from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const iconsDir = path.resolve(__dirname, '../../../data/icons');
const historyDir = path.resolve(iconsDir, 'history');
fs.mkdirSync(iconsDir, { recursive: true });
fs.mkdirSync(historyDir, { recursive: true });
// ── Suppression fond blanc SVG ────────────────────────────────────────────────
function isNearWhite(color) {
if (!color) return false;
const c = color.trim().toLowerCase();
if (c === 'white' || c === 'snow') return true;
const s3 = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (s3) {
return parseInt(s3[1] + s3[1], 16) >= 240 &&
parseInt(s3[2] + s3[2], 16) >= 240 &&
parseInt(s3[3] + s3[3], 16) >= 240;
}
const s6 = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (s6) {
return parseInt(s6[1], 16) >= 240 &&
parseInt(s6[2], 16) >= 240 &&
parseInt(s6[3], 16) >= 240;
}
const rgb = c.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
if (rgb) {
return parseInt(rgb[1]) >= 240 && parseInt(rgb[2]) >= 240 && parseInt(rgb[3]) >= 240;
}
return false;
}
function getFillFromElement(str) {
const fa = str.match(/\bfill\s*=\s*["']([^"']*)["']/i);
if (fa) return fa[1];
const sa = str.match(/\bstyle\s*=\s*["']([^"']*)["']/i);
if (sa) {
const fm = sa[1].match(/(?:^|;)\s*fill\s*:\s*([^;]+)/i);
if (fm) return fm[1].trim();
}
return null;
}
function pathCoversCanvas(d, vbW, vbH) {
if (!vbW || !vbH) return true;
const nums = (d.match(/[\d.]+/g) || []).map(parseFloat);
const hasNearZero = nums.some(v => v <= 5);
const hasMaxX = nums.some(v => Math.abs(v - vbW) <= 5);
const hasMaxY = nums.some(v => Math.abs(v - vbH) <= 5);
return hasNearZero && hasMaxX && hasMaxY;
}
function stripSvgBackground(svgContent) {
let out = svgContent;
// 1. Remove background-color from style="" on <svg>
out = out.replace(
/(<svg\b[^>]*)\sstyle="([^"]*)"/i,
(_, tag, style) => {
const cleaned = style.split(';')
.filter(s => !/^\s*background(-color)?\s*:/i.test(s))
.join(';').replace(/^;+|;+$/g, '');
return cleaned ? `${tag} style="${cleaned}"` : tag;
}
);
// 2. Remove enable-background (Adobe Illustrator artifact)
out = out.replace(/\s+enable-background="[^"]*"/gi, '');
// 3. Parse viewBox for canvas coverage check
const vbMatch = out.match(/\bviewBox\s*=\s*["']\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i);
const vbW = vbMatch ? parseFloat(vbMatch[1]) : 0;
const vbH = vbMatch ? parseFloat(vbMatch[2]) : 0;
// 4. Remove near-white <rect> elements at origin covering the canvas
out = out.replace(/<rect(\s[^>]*)?\/?>/gis, (match) => {
const fill = getFillFromElement(match);
if (!fill || !isNearWhite(fill)) return match;
const x = match.match(/\bx\s*=\s*["']?([^"'\s>]+)/i);
const y = match.match(/\by\s*=\s*["']?([^"'\s>]+)/i);
if ((x && parseFloat(x[1]) > 5) || (y && parseFloat(y[1]) > 5)) return match;
const w = match.match(/\bwidth\s*=\s*["']?([^"'\s>]+)/i);
const h = match.match(/\bheight\s*=\s*["']?([^"'\s>]+)/i);
if (!w || !h) return match;
return '';
});
out = out.replace(/<\/rect>/gi, '');
// 5. Remove near-white <path> elements covering the canvas
// (raster-trace backgrounds from design tools like GIMP/Inkscape export)
out = out.replace(/<path\b[^>]*\/?>/gis, (match) => {
const fill = getFillFromElement(match);
if (!fill || !isNearWhite(fill)) return match;
const dAttr = match.match(/\bd\s*=\s*["']([^"']*?)["']/is);
if (!dAttr) return match;
if (pathCoversCanvas(dAttr[1], vbW, vbH)) return '';
return match;
});
out = out.replace(/<\/path>/gi, '');
out = out.replace(/\n{3,}/g, '\n\n');
return out;
}
// ── Retraitement post-upload ───────────────────────────────────────────────────
// Retourne le chemin final du fichier (peut changer si JPG/WebP converti en PNG)
async function processUploadedFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
// ── SVG : suppression fond blanc en pur texte ──────────────────
if (ext === '.svg') {
try {
const original = fs.readFileSync(filePath, 'utf8');
const cleaned = stripSvgBackground(original);
if (cleaned !== original) fs.writeFileSync(filePath, cleaned, 'utf8');
} catch (e) {
console.warn('[icons] stripSvgBackground failed:', e.message);
}
return filePath;
}
// ── Raster (PNG / JPG / WebP) : fond blanc → transparent via sharp ──
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
// La transparence nécessite PNG — on convertit si besoin
const pngPath = filePath.replace(/\.(jpg|jpeg|webp|png)$/i, '.png');
try {
const { data, info } = await sharp(filePath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
// Rendre transparents les pixels blanc ou quasi-blanc (seuil > 240/255)
for (let i = 0; i < data.length; i += 4) {
if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) {
data[i + 3] = 0;
}
}
await sharp(data, {
raw: { width: info.width, height: info.height, channels: 4 },
}).png({ compressionLevel: 8 }).toFile(pngPath);
// Supprimer l'original s'il a changé d'extension
if (pngPath !== filePath) fs.unlinkSync(filePath);
return pngPath;
} catch (e) {
console.warn('[icons] sharp processing failed:', e.message);
return filePath; // garder le fichier original en cas d'erreur
}
}
return filePath;
}
const router = Router();
// ── Multer ────────────────────────────────────────────────────────────────────
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, iconsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.svg';
const name = req.params.name || req.body?.name || 'icon';
cb(null, `icon_${name}_${Date.now()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 Mo max
fileFilter: (_req, file, cb) => {
const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp'];
if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true);
else cb(new Error('Format non supporté — SVG, PNG, JPG ou WebP uniquement'));
},
});
// ── Validation slug ───────────────────────────────────────────────────────────
function isValidSlug(s) {
return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(s) && s.length <= 64;
}
// ── GET /api/icons — liste toutes les icônes (authentifié) ───────────────────
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT id, name, filename, description, created_at, updated_at
FROM app_icons
ORDER BY name
`).all();
res.json(rows);
});
// ── GET /api/icons/:name — détail d'une icône ────────────────────────────────
router.get('/:name', (req, res) => {
const icon = db.prepare('SELECT * FROM app_icons WHERE name = ?').get(req.params.name);
if (!icon) return res.status(404).json({ error: 'Icône introuvable' });
res.json(icon);
});
// ── GET /api/icons/:name/history — historique des versions ──────────────────
router.get('/:name/history', requireAdmin, (req, res) => {
const icon = db.prepare('SELECT id FROM app_icons WHERE name = ?').get(req.params.name);
if (!icon) return res.status(404).json({ error: 'Icône introuvable' });
const rows = db.prepare(`
SELECT id, filename, replaced_at
FROM app_icons_history
WHERE icon_id = ?
ORDER BY replaced_at DESC
`).all(icon.id);
res.json(rows);
});
// ── POST /api/icons — créer une nouvelle association nom/image ───────────────
router.post('/', requireAdmin, upload.single('file'), async (req, res, next) => {
try {
const name = (req.body.name || '').trim().toLowerCase();
const description = (req.body.description || '').trim() || null;
if (!name) return res.status(400).json({ error: 'Le nom est requis' });
if (!isValidSlug(name)) return res.status(400).json({ error: 'Nom invalide — lettres minuscules, chiffres et tirets uniquement' });
if (!req.file) return res.status(400).json({ error: 'Fichier requis' });
// Renommer le fichier avec le bon nom maintenant qu'on a le slug
const ext = path.extname(req.file.originalname).toLowerCase() || '.svg';
const newFilename = `icon_${name}_${Date.now()}${ext}`;
fs.renameSync(req.file.path, path.join(iconsDir, newFilename));
const finalPath = await processUploadedFile(path.join(iconsDir, newFilename));
const finalFilename = path.basename(finalPath);
const row = db.prepare(`
INSERT INTO app_icons (name, filename, description)
VALUES (?, ?, ?)
RETURNING *
`).get(name, finalFilename, description);
res.status(201).json(row);
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
if (req.file) fs.unlinkSync(req.file.path).catch?.(() => {});
return res.status(409).json({ error: `Le nom "${req.body?.name}" existe déjà` });
}
next(err);
}
});
// ── PUT /api/icons/:name — remplacer l'image (archive l'ancienne) ────────────
router.put('/:name', requireAdmin, upload.single('file'), async (req, res, next) => {
try {
const icon = db.prepare('SELECT * FROM app_icons WHERE name = ?').get(req.params.name);
if (!icon) return res.status(404).json({ error: 'Icône introuvable' });
if (!req.file) return res.status(400).json({ error: 'Fichier requis' });
const ext = path.extname(req.file.originalname).toLowerCase() || '.svg';
const newFilename = `icon_${icon.name}_${Date.now()}${ext}`;
fs.renameSync(req.file.path, path.join(iconsDir, newFilename));
const finalPath = await processUploadedFile(path.join(iconsDir, newFilename));
const finalFilename = path.basename(finalPath);
const doReplace = db.transaction(() => {
// Archiver l'ancienne version
db.prepare(`
INSERT INTO app_icons_history (icon_id, filename, replaced_at)
VALUES (?, ?, datetime('now'))
`).run(icon.id, icon.filename);
// Mettre à jour l'entrée principale
return db.prepare(`
UPDATE app_icons SET filename = ?, updated_at = datetime('now')
WHERE id = ?
RETURNING *
`).get(finalFilename, icon.id);
});
const updated = doReplace();
// Supprimer les fichiers d'historique au-delà de 10 versions
const old = db.prepare(`
SELECT id, filename FROM app_icons_history
WHERE icon_id = ?
ORDER BY replaced_at DESC
LIMIT -1 OFFSET 10
`).all(icon.id);
for (const o of old) {
fs.unlink(path.join(iconsDir, o.filename), () => {});
db.prepare('DELETE FROM app_icons_history WHERE id = ?').run(o.id);
}
res.json(updated);
} catch (err) {
next(err);
}
});
// ── PATCH /api/icons/:name — modifier description uniquement ─────────────────
router.patch('/:name', requireAdmin, (req, res) => {
const { description } = req.body;
const updated = db.prepare(`
UPDATE app_icons SET description = ?, updated_at = datetime('now')
WHERE name = ?
RETURNING *
`).get(description ?? null, req.params.name);
if (!updated) return res.status(404).json({ error: 'Icône introuvable' });
res.json(updated);
});
export default router;
+531
View File
@@ -0,0 +1,531 @@
import { Router } from 'express';
import multer from 'multer';
import xlsx from 'xlsx';
import path from 'node:path';
import fs from 'node:fs';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
const router = Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve('./uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const upload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
});
/**
* Step 1: POST /api/imports/preview
* multipart/form-data with `file` (xlsx/csv)
* -> returns: { headers: [...], sampleRows: [...], allRowCount, tempId }
*
* Step 2: POST /api/imports/apply
* body: { tempId, module, mapping, defaults }
* -> applies the mapping and inserts rows in the chosen module
*/
// Modules scoped to a specific investisseur (require X-Investisseur-Id)
const INVESTISSEUR_SCOPED = ['depots_retraits', 'investissements', 'remboursements'];
const MODULES = {
depots_retraits: {
requiredTargets: ['plateforme_id', 'date_operation', 'type', 'montant'],
optionalTargets: ['libelle', 'reference', 'notes'],
},
investissements: {
requiredTargets: ['plateforme_id', 'nom_projet', 'date_souscription', 'montant_investi'],
optionalTargets: ['emetteur','date_premiere_echeance','date_cible','taux_interet','duree_mois','type_remb','freq_interets','statut','reference','notes'],
},
remboursements: {
requiredTargets: ['investissement_id', 'date_remb'],
optionalTargets: ['capital','cashback','interets_bruts','prelev_sociaux','prelev_forfaitaire','statut','notes'],
},
plateformes: {
requiredTargets: ['nom'],
optionalTargets: ['url', 'notes'],
},
taux_pfu: {
requiredTargets: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
optionalTargets: [],
},
};
/** Parse un fichier uploadé selon son extension → tableau d'objets */
function parseFile(filePath, originalName) {
const ext = path.extname(originalName).toLowerCase();
if (ext === '.json') {
const content = fs.readFileSync(filePath, 'utf8');
let parsed;
try { parsed = JSON.parse(content); } catch {
throw new HttpError(400, 'Fichier JSON invalide — vérifiez la syntaxe');
}
if (!Array.isArray(parsed)) {
throw new HttpError(400, 'Le fichier JSON doit contenir un tableau d\'objets à la racine');
}
return { rows: parsed, sheetName: 'json' };
}
// Excel / CSV
const wb = xlsx.readFile(filePath, { cellDates: true });
const sheetName = wb.SheetNames[0];
const ws = wb.Sheets[sheetName];
return { rows: xlsx.utils.sheet_to_json(ws, { defval: null, raw: false }), sheetName };
}
router.post('/preview', upload.single('file'), (req, res, next) => {
try {
if (!req.file) throw new HttpError(400, 'No file uploaded');
const { rows, sheetName } = parseFile(req.file.path, req.file.originalname);
const headers = rows.length ? Object.keys(rows[0]) : [];
res.json({
tempId: path.basename(req.file.path),
filename: req.file.originalname,
sheetName,
headers,
sampleRows: rows.slice(0, 10),
allRowCount: rows.length,
});
} catch (e) { next(e); }
});
router.post('/apply', (req, res, next) => {
try {
const { tempId, module, mapping, defaults = {} } = req.body || {};
if (!tempId || !module || !mapping) {
throw new HttpError(400, 'tempId, module and mapping are required');
}
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
// Require a specific investisseur for transactional modules
if (INVESTISSEUR_SCOPED.includes(module)) {
requireInvestisseur(req, res, () => {});
}
const tempPath = path.join(UPLOAD_DIR, tempId);
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
for (const target of def.requiredTargets) {
if (!mapping[target] && defaults[target] === undefined) {
throw new HttpError(400, `Missing mapping for required field: ${target}`);
}
}
// Déterminer la source selon l'extension du fichier original (passé en body optionnel)
const origName = req.body.originalFilename || '';
const isJson = path.extname(origName).toLowerCase() === '.json';
const srcLabel = isJson ? 'import_json' : 'import_excel';
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
let inserted = 0, skipped = 0;
const errors = [];
const tx = db.transaction(() => {
for (let idx = 0; idx < rows.length; idx++) {
const row = rows[idx];
try {
const v = (target) => {
const col = mapping[target];
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') {
return row[col];
}
return defaults[target];
};
if (module === 'depots_retraits') {
db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
VALUES (?,?,?,?,?,?,?,?)
`).run(
req.investisseur.id,
Number(v('plateforme_id')),
normaliseDate(v('date_operation')),
normaliseType(v('type')),
num(v('montant')),
v('libelle') || null,
v('reference') || null,
srcLabel,
);
} else if (module === 'investissements') {
db.prepare(`
INSERT INTO investissements
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
date_premiere_echeance, date_cible, montant_investi, taux_interet, duree_mois,
type_remb, freq_interets, statut, reference, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
req.investisseur.id,
Number(v('plateforme_id')),
String(v('nom_projet')),
v('emetteur') || null,
normaliseDate(v('date_souscription')),
v('date_premiere_echeance') ? normaliseDate(v('date_premiere_echeance')) : null,
v('date_cible') ? normaliseDate(v('date_cible')) : null,
num(v('montant_investi')),
v('taux_interet') ? Number(String(v('taux_interet')).replace(',', '.')) : null,
v('duree_mois') ? parseInt(v('duree_mois'), 10) : null,
v('type_remb') || null,
v('freq_interets') || 'mensuel',
v('statut') || 'en_cours',
v('reference') || null,
srcLabel,
);
} else if (module === 'remboursements') {
const capital = num(v('capital'));
const cashback = num(v('cashback'));
const bruts = num(v('interets_bruts'));
const ps = num(v('prelev_sociaux'));
const pf = num(v('prelev_forfaitaire'));
const interets_nets = Math.round((bruts - ps - pf) * 100) / 100;
const net_recu = Math.round((capital + cashback + interets_nets) * 100) / 100;
db.prepare(`
INSERT INTO remboursements
(investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux,
prelev_forfaitaire, interets_nets, net_recu, statut, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(
Number(v('investissement_id')),
normaliseDate(v('date_remb')),
capital, cashback, bruts, ps, pf, interets_nets, net_recu,
v('statut') || 'paye',
srcLabel,
);
} else if (module === 'plateformes') {
const nom = String(v('nom') || '').trim();
if (!nom) throw new Error('Le champ nom est vide');
const r = db.prepare(`
INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes)
VALUES (?, ?, ?, ?)
`).run(
req.user.id,
nom,
v('url') || null,
v('notes') || null,
);
// changes = 0 means the row was ignored (nom already exists)
if (r.changes === 0) throw new Error(`Plateforme "${nom}" existe déjà — ignorée`);
} else if (module === 'taux_pfu') {
const annee = parseInt(v('annee'), 10);
if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide');
db.prepare(`
INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux)
VALUES (?, ?, ?, ?)
ON CONFLICT(annee) DO UPDATE SET
pfu_total = excluded.pfu_total,
impot_revenu = excluded.impot_revenu,
prelev_sociaux = excluded.prelev_sociaux,
updated_at = datetime('now')
`).run(
annee,
num(v('pfu_total')),
num(v('impot_revenu')),
num(v('prelev_sociaux')),
);
}
inserted++;
} catch (err) {
skipped++;
errors.push({ row: idx + 2, error: err.message });
}
}
});
tx();
db.prepare(`
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json)
VALUES (?,?,?,?,?,?,?,?)
`).run(
req.user.id,
req.investisseur?.id ?? null,
module,
tempId,
rows.length,
inserted,
skipped,
JSON.stringify(mapping),
);
// Clean up temp file
try { fs.unlinkSync(tempPath); } catch { /* */ }
res.json({ inserted, skipped, total: rows.length, errors: errors.slice(0, 50) });
} catch (e) { next(e); }
});
/**
* POST /api/imports/dossier
* Importe un dossier investissement complet (format d'export natif).
* Scénario CREATE : le dossier n'existe pas → création complète.
* Scénario UPDATE : le dossier existe déjà → mise à jour des champs + remboursements manquants.
* Identification : (investisseur_id, nom_projet, date_souscription) — clé naturelle portable.
*/
router.post('/dossier', (req, res, next) => {
try {
requireInvestisseur(req, res, () => {});
const { dossier } = req.body || {};
if (!dossier || dossier.type !== 'dossier_investissement') {
throw new HttpError(400, 'Format invalide — attendu { dossier: { type: "dossier_investissement", ... } }');
}
const { investissement: inv, plateforme: platInfo, remboursements = [], reinvestissements = [], historique = [] } = dossier;
if (!inv?.nom_projet || !inv?.date_souscription) {
throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription');
}
let action, investissementId;
const tx = db.transaction(() => {
/* ── 1. Résoudre / créer la plateforme ─────────────────── */
let platRow = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?')
.get(req.user.id, platInfo?.nom || '');
if (!platRow && platInfo?.nom) {
const r = db.prepare('INSERT INTO plateformes (user_id, nom, url) VALUES (?,?,?)')
.run(req.user.id, platInfo.nom, platInfo.url || null);
platRow = { id: r.lastInsertRowid };
}
if (!platRow) throw new HttpError(400, 'Plateforme introuvable et nom manquant dans le dossier');
const plateformeId = platRow.id;
/* ── 2. Chercher l'investissement existant (clé naturelle) */
const existing = db.prepare(`
SELECT id FROM investissements
WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, inv.nom_projet, inv.date_souscription);
if (!existing) {
/* ────────────── SCÉNARIO CREATE ──────────────────────── */
const r = db.prepare(`
INSERT INTO investissements
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
date_premiere_echeance, date_cible, date_debut_simul, montant_investi,
taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
`).run(
req.investisseur.id, plateformeId,
inv.nom_projet, inv.emetteur || null,
inv.date_souscription,
inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null,
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
inv.statut || 'en_cours', inv.reference || null, inv.notes || null,
);
investissementId = Number(r.lastInsertRowid);
// Remboursements
for (const rb of remboursements) {
db.prepare(`
INSERT INTO remboursements
(investissement_id, date_remb, capital, cashback, interets_bruts,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
`).run(
investissementId, rb.date_remb,
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
rb.interets_nets || 0, rb.net_recu || 0,
rb.statut || 'paye', rb.notes || null,
);
}
// Réinvestissements
for (const rv of reinvestissements) {
if (!rv.date_reinvestissement || !rv.montant) continue;
db.prepare(`
INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source)
VALUES (?, ?, ?, ?, ?)
`).run(
investissementId,
Number(rv.montant),
rv.date_reinvestissement,
rv.note || null,
rv.source || 'manuel',
);
}
// Historique (lecture seule — on importe pour la traçabilité)
for (const h of historique) {
db.prepare(`
INSERT INTO investissement_historique
(investissement_id, type_evenement, changements, notes, created_at)
VALUES (?,?,?,?,?)
`).run(
investissementId,
h.type_evenement || 'import',
typeof h.changements === 'string' ? h.changements : JSON.stringify(h.changements || []),
h.notes || null,
h.created_at || null,
);
}
// Entrée d'historique de l'import lui-même
db.prepare(`
INSERT INTO investissement_historique (investissement_id, type_evenement, changements)
VALUES (?, 'import', ?)
`).run(investissementId, JSON.stringify([{
champ: 'import', label: 'Import dossier',
ancienne_valeur: null, nouvelle_valeur: dossier.exported_at || 'inconnu',
}]));
action = 'created';
} else {
/* ────────────── SCÉNARIO UPDATE ──────────────────────── */
investissementId = existing.id;
db.prepare(`
UPDATE investissements SET
plateforme_id = ?, emetteur = ?,
date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?,
montant_investi = ?, taux_interet = ?, duree_mois = ?,
type_remb = ?, freq_interets = ?, statut = ?,
reference = ?, notes = ?
WHERE id = ?
`).run(
plateformeId, inv.emetteur || null,
inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null,
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
inv.statut || 'en_cours',
inv.reference || null, inv.notes || null,
investissementId,
);
// Remboursements : ajouter les manquants (clé naturelle = date_remb + capital + interets_bruts)
let rembInserted = 0;
for (const rb of remboursements) {
const exists = db.prepare(`
SELECT id FROM remboursements
WHERE investissement_id = ? AND date_remb = ?
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
`).get(investissementId, rb.date_remb, rb.capital || 0, rb.interets_bruts || 0);
if (!exists) {
db.prepare(`
INSERT INTO remboursements
(investissement_id, date_remb, capital, cashback, interets_bruts,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
`).run(
investissementId, rb.date_remb,
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
rb.interets_nets || 0, rb.net_recu || 0,
rb.statut || 'paye', rb.notes || null,
);
rembInserted++;
}
}
// Réinvestissements : ajouter les manquants (clé = date + montant)
let reinvInserted = 0;
for (const rv of reinvestissements) {
if (!rv.date_reinvestissement || !rv.montant) continue;
const rvExists = db.prepare(`
SELECT id FROM reinvestissements
WHERE investissement_id = ? AND date_reinvestissement = ? AND ABS(montant - ?) < 0.005
`).get(investissementId, rv.date_reinvestissement, Number(rv.montant));
if (!rvExists) {
db.prepare(`
INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source)
VALUES (?, ?, ?, ?, ?)
`).run(
investissementId,
Number(rv.montant),
rv.date_reinvestissement,
rv.note || null,
rv.source || 'manuel',
);
reinvInserted++;
}
}
// Entrée d'historique de la mise à jour
db.prepare(`
INSERT INTO investissement_historique (investissement_id, type_evenement, changements)
VALUES (?, 'import', ?)
`).run(investissementId, JSON.stringify([{
champ: 'import', label: 'Mise à jour dossier',
ancienne_valeur: null,
nouvelle_valeur: `${dossier.exported_at || 'inconnu'}${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`,
}]));
action = 'updated';
}
});
tx();
/* ── 3. Régénérer la simulation ─────────────────────────── */
const fresh = db.prepare('SELECT * FROM investissements WHERE id = ?').get(investissementId);
generateSimulWithReinvestissements(db, investissementId);
/* ── 4. Log import ──────────────────────────────────────── */
db.prepare(`
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json)
VALUES (?,?,?,?,?,?,?,?)
`).run(
req.user.id, req.investisseur.id,
'dossier_investissement',
`Dossier_${inv.nom_projet}`,
1, 1, 0,
JSON.stringify({ action, investissementId }),
);
res.json({ action, investissementId });
} catch (e) { next(e); }
});
router.get('/history', (req, res) => {
const rows = db.prepare(`
SELECT * FROM imports WHERE user_id=? ORDER BY id DESC LIMIT 100
`).all(req.user.id);
res.json(rows);
});
// helpers
/** Supprime les accents/diacritiques d'une chaîne (ex. "Dépôt" → "Depot") */
function stripAccents(s) {
return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '');
}
/** Convertit une valeur monétaire en nombre (gère "€", espaces, virgule décimale) */
function num(v) {
if (v === undefined || v === null || v === '') return 0;
// Supprimer tout ce qui n'est pas chiffre, virgule, point ou signe moins
const clean = String(v).replace(/[^\d,.-]/g, '').replace(',', '.');
const n = Number(clean);
return isNaN(n) ? 0 : n;
}
function normaliseType(v) {
// Normalise les accents avant la comparaison : "Dépôt" → "depot"
const s = stripAccents(String(v || '')).toLowerCase();
if (s.startsWith('dep') || s === 'versement' || s === 'in') return 'depot';
if (s.startsWith('ret') || s === 'withdrawal' || s === 'out') return 'retrait';
return s; // CHECK constraint will reject if invalid
}
function normaliseDate(v) {
if (!v) return null;
if (v instanceof Date) return v.toISOString().slice(0, 10);
const s = String(v).trim();
// Already ISO
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
// dd/mm/yyyy
const m = s.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/);
if (m) {
const [_, d, mo, y] = m;
const yy = y.length === 2 ? '20' + y : y;
return `${yy}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
}
return s;
}
export default router;
+457
View File
@@ -0,0 +1,457 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimul } from '../utils/schedule.js';
const router = Router();
const TRACKED_FIELDS = [
{ key: 'type_remb', label: 'Type de prêt' },
{ key: 'taux_interet', label: 'Taux annuel (%)' },
{ key: 'duree_mois', label: 'Durée (mois)' },
{ key: 'montant_investi', label: 'Montant investi (€)' },
{ key: 'statut', label: 'Statut' },
{ key: 'freq_interets', label: 'Fréquence des intérêts' },
{ key: 'date_premiere_echeance', label: 'Date 1ère échéance' },
{ key: 'date_cible', label: 'Date cible' },
{ key: 'date_debut_simul', label: 'Date de restructuration' },
{ key: 'plateforme_id', label: 'Plateforme' },
];
function recordHistory(investissementId, { type_evenement, changements, notes }) {
if (!changements || changements.length === 0) return;
db.prepare(`
INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes)
VALUES (?, ?, ?, ?)
`).run(investissementId, type_evenement, JSON.stringify(changements), notes || null);
}
function detectChangements(ancien, nouveau) {
const diffs = [];
for (const { key, label } of TRACKED_FIELDS) {
const av = ancien[key] ?? null;
const nv = nouveau[key] ?? null;
const avNorm = av === '' ? null : av;
const nvNorm = nv === '' ? null : nv;
if (String(avNorm) !== String(nvNorm)) {
diffs.push({ champ: key, label, ancienne_valeur: avNorm, nouvelle_valeur: nvNorm });
}
}
return diffs;
}
function detectTypeEvenement(changements) {
const champsRestructuration = ['type_remb', 'date_debut_simul'];
if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration';
return 'modification';
}
const Schema = z.object({
investisseur_id: z.number().int().positive().optional(),
plateforme_id: z.number().int().positive(),
nom_projet: z.string().min(1),
emetteur: z.string().optional(),
date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
date_premiere_echeance: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
date_cible: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
date_debut_simul: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
montant_investi: z.number().positive(),
taux_interet: z.number().optional(),
duree_mois: z.number().int().optional(),
type_remb: z.enum(['in_fine','amortissable','differe']).optional().or(z.literal('')),
freq_interets: z.enum(['mensuel','trimestriel','in_fine']).default('mensuel'),
statut: z.enum(['en_cours','rembourse','en_retard','procedure','cloture']).default('en_cours'),
reference: z.string().optional(),
notes: z.string().optional(),
categorie_id: z.number().int().positive().nullable().optional(),
echeance_fin_de_mois: z.number().int().min(0).max(1).optional().default(0),
methode_remboursement: z.enum(['portefeuille','compte_courant']).nullable().optional(),
nom_compte_courant: z.string().nullable().optional(),
compte_id: z.number().int().positive().nullable().optional(),
pays_exposition: z.string().length(2).optional().default('FR'),
});
router.use(requireInvestisseur);
function resolveInvestisseurId(req, bodyInvestisseurId) {
if (!bodyInvestisseurId) return req.investisseur.id;
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(bodyInvestisseurId, req.user.id);
if (!row) throw new HttpError(403, 'Investisseur non autorisé');
return bodyInvestisseurId;
}
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const { statut, plateforme_id } = req.query;
const conds = scopeAll
? ['i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)']
: ['i.investisseur_id = ?'];
const args = scopeAll ? [req.user.id] : [req.investisseur.id];
if (statut) { conds.push('i.statut = ?'); args.push(statut); }
if (plateforme_id){ conds.push('i.plateforme_id = ?'); args.push(Number(plateforme_id)); }
const rows = db.prepare(`
SELECT i.*, p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
cp.nom AS categorie_nom,
plat_inv.nom AS plateforme_detenteur_nom,
c.id AS compte_id, c.nom AS compte_nom, c.type AS compte_type,
(SELECT COALESCE(SUM(r.capital),0) FROM remboursements r WHERE r.investissement_id = i.id) AS capital_rembourse,
(SELECT COALESCE(SUM(r.interets_bruts),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_percus,
(SELECT COALESCE(SUM(r.interets_nets),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_nets_total,
(SELECT COALESCE(SUM(r.net_recu),0) FROM remboursements r WHERE r.investissement_id = i.id) AS net_recu_total,
(SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS reinvestissements_total,
i.montant_investi + (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS capital_total
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id
LEFT JOIN comptes c ON c.id = i.compte_id
WHERE ${conds.join(' AND ')}
ORDER BY i.date_souscription DESC, i.id DESC
`).all(...args);
// Attacher les associations catégories/secteurs d'investissement
if (rows.length > 0) {
const ids = rows.map(r => r.id);
const placeholders = ids.map(() => '?').join(',');
const cats = db.prepare(`
SELECT ic.investissement_id, c.id, c.nom,
CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM investissement_categories_inv ic
JOIN categories_inv c ON c.id = ic.categorie_id
WHERE ic.investissement_id IN (${placeholders})
ORDER BY is_global DESC, c.nom
`).all(...ids);
const sects = db.prepare(`
SELECT is2.investissement_id, s.id, s.nom,
CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM investissement_secteurs_inv is2
JOIN secteurs_inv s ON s.id = is2.secteur_id
WHERE is2.investissement_id IN (${placeholders})
ORDER BY is_global DESC, s.nom
`).all(...ids);
const catMap = {};
const sectMap = {};
for (const c of cats) { if (!catMap[c.investissement_id]) catMap[c.investissement_id] = []; catMap[c.investissement_id].push({ id: c.id, nom: c.nom, is_global: c.is_global }); }
for (const s of sects) { if (!sectMap[s.investissement_id]) sectMap[s.investissement_id] = []; sectMap[s.investissement_id].push({ id: s.id, nom: s.nom, is_global: s.is_global }); }
for (const r of rows) { r.categories_inv = catMap[r.id] || []; r.secteurs_inv = sectMap[r.id] || []; }
}
res.json(rows);
});
// Retourne les comptes bancaires d'un investisseur donné (pour le select dans le formulaire)
router.get('/comptes-par-investisseur/:investisseur_id', (req, res, next) => {
try {
const invId = Number(req.params.investisseur_id);
// Vérifie que l'investisseur appartient à l'utilisateur
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(invId, req.user.id);
if (!inv) throw new HttpError(403, 'Investisseur non autorisé');
const rows = db.prepare(
'SELECT id, nom, type, banque FROM comptes WHERE investisseur_id = ? AND user_id = ? ORDER BY type, nom'
).all(invId, req.user.id);
res.json(rows);
} catch (e) { next(e); }
});
router.get('/comptes-courants', (req, res) => {
const rows = db.prepare(`
SELECT DISTINCT i.investisseur_id, i.nom_compte_courant
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.nom_compte_courant IS NOT NULL AND i.nom_compte_courant != ''
ORDER BY i.nom_compte_courant
`).all(req.user.id);
res.json(rows);
});
// POST /api/investissements/fix-differe-dates
// Corrige date_premiere_echeance et date_cible des prêts différés dont les dates
// s'écartent de plus de 2 ans par rapport à date_souscription + duree_mois.
router.post('/fix-differe-dates', (req, res, next) => {
try {
const rows = db.prepare(`
SELECT i.id, i.nom_projet, i.date_souscription, i.duree_mois,
i.date_premiere_echeance, i.date_cible
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.type_remb = 'differe'
AND i.date_souscription IS NOT NULL
AND i.duree_mois IS NOT NULL
`).all(req.user.id);
const SEUIL_JOURS = 730; // 2 ans
function addMonths(dateStr, months) {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCMonth(d.getUTCMonth() + months);
return d.toISOString().slice(0, 10);
}
function diffJours(a, b) {
return Math.abs((new Date(a + 'T00:00:00Z') - new Date(b + 'T00:00:00Z')) / 86400000);
}
const corriges = [];
const stmt = db.prepare(`
UPDATE investissements
SET date_premiere_echeance = ?, date_cible = ?, updated_at = datetime('now')
WHERE id = ?
`);
for (const inv of rows) {
const dateCalculee = addMonths(inv.date_souscription, inv.duree_mois);
const ecartEcheance = inv.date_premiere_echeance
? diffJours(inv.date_premiere_echeance, dateCalculee) : null;
const ecartCible = inv.date_cible
? diffJours(inv.date_cible, dateCalculee) : null;
const incoherent =
(ecartEcheance !== null && ecartEcheance > SEUIL_JOURS) ||
(ecartCible !== null && ecartCible > SEUIL_JOURS) ||
(inv.date_premiere_echeance === null) ||
(inv.date_cible === null);
if (incoherent) {
stmt.run(dateCalculee, dateCalculee, inv.id);
corriges.push({
id: inv.id,
nom_projet: inv.nom_projet,
date_souscription: inv.date_souscription,
duree_mois: inv.duree_mois,
ancienne_date_premiere_echeance: inv.date_premiere_echeance,
ancienne_date_cible: inv.date_cible,
nouvelle_date: dateCalculee,
});
}
}
res.json({ updated: corriges.length, detail: corriges });
} catch (err) {
next(err);
}
});
router.get('/:id', (req, res, next) => {
try {
const inv = db.prepare(`
SELECT i.*, p.nom AS plateforme_nom, p.fiscalite AS plateforme_fiscalite, p.logo_filename AS plateforme_logo, cp.nom AS categorie_nom,
c.id AS compte_id, c.nom AS compte_nom, c.type AS compte_type
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id
LEFT JOIN comptes c ON c.id = i.compte_id
WHERE i.id = ?
`).get(req.user.id, req.params.id);
if (!inv) throw new HttpError(404, 'Not found');
const remboursements = db.prepare(
'SELECT * FROM remboursements WHERE investissement_id = ? ORDER BY date_remb'
).all(req.params.id);
const simul = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance'
).all(req.params.id);
const historique = db.prepare(
'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC'
).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) }));
const reinvestissements = db.prepare(
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(req.params.id);
const reinvestissements_total = reinvestissements.reduce((s, r) => s + r.montant, 0);
const capital_total = inv.montant_investi + reinvestissements_total;
// Associations catégories/secteurs
const categories_inv = db.prepare(`
SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM investissement_categories_inv ic
JOIN categories_inv c ON c.id = ic.categorie_id
WHERE ic.investissement_id = ?
ORDER BY is_global DESC, c.nom
`).all(req.params.id);
const secteurs_inv = db.prepare(`
SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM investissement_secteurs_inv is2
JOIN secteurs_inv s ON s.id = is2.secteur_id
WHERE is2.investissement_id = ?
ORDER BY is_global DESC, s.nom
`).all(req.params.id);
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, reinvestissements, categories_inv, secteurs_inv });
} catch (e) { next(e); }
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
const r = db.prepare(`
INSERT INTO investissements
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
date_premiere_echeance, date_cible, date_debut_simul, montant_investi, taux_interet, duree_mois,
type_remb, freq_interets, statut, reference, source, notes, categorie_id, echeance_fin_de_mois,
methode_remboursement, nom_compte_courant, compte_id, pays_exposition)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?,?,?,?,?,?,?)
`).run(
investisseurId, body.plateforme_id, body.nom_projet, body.emetteur || null,
body.date_souscription, body.date_premiere_echeance || null, body.date_cible || null,
body.date_debut_simul || null, body.montant_investi, body.taux_interet ?? null, body.duree_mois ?? null,
body.type_remb || null, body.freq_interets, body.statut, body.reference || null, body.notes || null,
body.categorie_id ?? null, body.echeance_fin_de_mois ?? 0,
body.methode_remboursement ?? null,
body.nom_compte_courant || null,
body.compte_id ?? null,
body.pays_exposition ?? 'FR',
);
const newId = r.lastInsertRowid;
recordHistory(newId, {
type_evenement: 'creation',
changements: [{ champ: 'creation', label: 'Création', ancienne_valeur: null, nouvelle_valeur: body.nom_projet }],
});
generateSimul(db, {
id: newId,
montant_investi: body.montant_investi,
taux_interet: body.taux_interet ?? null,
duree_mois: body.duree_mois ?? null,
type_remb: body.type_remb || 'in_fine',
freq_interets: body.freq_interets || 'mensuel',
date_premiere_echeance: body.date_premiere_echeance || null,
date_debut_simul: body.date_debut_simul || null,
date_souscription: body.date_souscription,
echeance_fin_de_mois: body.echeance_fin_de_mois ?? 0,
});
res.status(201).json({ id: newId, ...body });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
const ancien = db.prepare(`
SELECT inv_data.*
FROM investissements inv_data
JOIN investisseurs inv ON inv.id = inv_data.investisseur_id AND inv.user_id = ?
WHERE inv_data.id = ?
`).get(req.user.id, req.params.id);
if (!ancien) throw new HttpError(404, 'Not found');
const r = db.prepare(`
UPDATE investissements
SET investisseur_id=?, plateforme_id=?, nom_projet=?, emetteur=?, date_souscription=?,
date_premiere_echeance=?, date_cible=?, date_debut_simul=?, montant_investi=?,
taux_interet=?, duree_mois=?, type_remb=?, freq_interets=?, statut=?,
reference=?, notes=?, categorie_id=?, echeance_fin_de_mois=?,
methode_remboursement=?, nom_compte_courant=?, compte_id=?, pays_exposition=?, updated_at=datetime('now')
WHERE id=?
`).run(
investisseurId, body.plateforme_id, body.nom_projet, body.emetteur || null,
body.date_souscription, body.date_premiere_echeance || null, body.date_cible || null,
body.date_debut_simul || null, body.montant_investi, body.taux_interet ?? null,
body.duree_mois ?? null, body.type_remb || null, body.freq_interets, body.statut,
body.reference || null, body.notes || null, body.categorie_id ?? null,
body.echeance_fin_de_mois ?? 0,
body.methode_remboursement ?? null,
body.nom_compte_courant || null,
body.compte_id ?? null,
body.pays_exposition ?? 'FR', req.params.id,
);
if (r.changes === 0) throw new HttpError(404, 'Not found');
// Cascade compte_id vers les remboursements existants de cet investissement
if (body.compte_id !== undefined) {
const newCompteId = body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null;
db.prepare(
"UPDATE remboursements SET compte_id=? WHERE investissement_id=? AND methode_remboursement='compte_courant' AND compte_id IS NOT NULL"
).run(newCompteId, req.params.id);
}
const changements = detectChangements(ancien, {
...body,
date_debut_simul: body.date_debut_simul || null,
date_premiere_echeance: body.date_premiere_echeance || null,
date_cible: body.date_cible || null,
});
if (changements.length > 0) {
recordHistory(Number(req.params.id), {
type_evenement: detectTypeEvenement(changements),
changements,
});
}
generateSimul(db, {
id: Number(req.params.id),
montant_investi: body.montant_investi,
taux_interet: body.taux_interet ?? null,
duree_mois: body.duree_mois ?? null,
type_remb: body.type_remb || 'in_fine',
freq_interets: body.freq_interets || 'mensuel',
date_premiere_echeance: body.date_premiere_echeance || null,
date_debut_simul: body.date_debut_simul || null,
date_souscription: body.date_souscription,
echeance_fin_de_mois: body.echeance_fin_de_mois ?? 0,
});
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
});
// PUT /api/investissements/:id/fiscalite-override { override: 'exonere' | null }
router.put('/:id/fiscalite-override', (req, res, next) => {
try {
const inv = db.prepare(
'SELECT id FROM investissements WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
).get(req.params.id, req.user.id);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const override = req.body.override === 'exonere' ? 'exonere' : null;
db.prepare('UPDATE investissements SET fiscalite_override = ? WHERE id = ?')
.run(override, req.params.id);
res.json({ fiscalite_override: override });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const r = db.prepare(`
DELETE FROM investissements
WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
`).run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
router.delete('/:id/historique/:hid', (req, res, next) => {
try {
const r = db.prepare(`
DELETE FROM investissement_historique
WHERE id = ? AND investissement_id = ?
AND EXISTS (
SELECT 1 FROM investissements
WHERE id = ? AND investisseur_id = ?
)
`).run(req.params.hid, req.params.id, req.params.id, req.investisseur.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
// PUT /api/investissements/:id/auto-reinvest { active: true|false }
router.put('/:id/auto-reinvest', (req, res, next) => {
try {
const inv = db.prepare(
'SELECT id FROM investissements WHERE id = ? AND investisseur_id = ?'
).get(req.params.id, req.investisseur.id);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const active = req.body.active ? 1 : 0;
db.prepare('UPDATE investissements SET auto_reinvest = ? WHERE id = ?')
.run(active, req.params.id);
res.json({ auto_reinvest: !!active });
} catch (e) { next(e); }
});
export default router;
+126
View File
@@ -0,0 +1,126 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const Schema = z.object({
nom: z.string().min(1),
prenom: z.string().optional().or(z.literal('')),
type: z.enum(['famille', 'entreprise']).default('famille'),
type_fiscal: z.string().optional(),
notes: z.string().optional(),
});
router.get('/', (req, res) => {
const rows = db
.prepare(
`SELECT id, nom, prenom, type, type_fiscal, is_principal, notes, created_at
FROM investisseurs WHERE user_id = ?
ORDER BY is_principal DESC, type, nom`
)
.all(req.user.id);
res.json(rows);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db
.prepare(
'INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes) VALUES (?,?,?,?,?,?)'
)
.run(
req.user.id,
body.nom,
body.prenom || null,
body.type,
body.type_fiscal || null,
body.notes || null,
);
const invId = r.lastInsertRowid;
// Auto-créer un compte courant pour ce nouveau profil
// body.nom contient déjà le nom complet (ex. "Marine CROGUENNEC")
const label = `Compte courant — ${body.nom}`;
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(req.user.id, label, 'compte_courant', invId);
res.status(201).json({ id: invId, ...body });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db
.prepare(
`UPDATE investisseurs SET nom=?, prenom=?, type=?, type_fiscal=?, notes=?, updated_at=datetime('now')
WHERE id=? AND user_id=?`
)
.run(
body.nom,
body.prenom || null,
body.type,
body.type_fiscal || null,
body.notes || null,
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); }
});
router.post('/reassign-to-principal', (req, res, next) => {
try {
const principal = db
.prepare('SELECT id FROM investisseurs WHERE user_id = ? AND is_principal = 1')
.get(req.user.id);
if (!principal) throw new HttpError(400, 'Aucun compte principal trouvé.');
const principalId = principal.id;
const userId = req.user.id;
const reassign = db.transaction(() => {
db.prepare(
`UPDATE investissements SET investisseur_id = ?
WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) AND investisseur_id != ?`
).run(principalId, userId, principalId);
db.prepare(
`UPDATE depots_retraits SET investisseur_id = ?
WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) AND investisseur_id != ?`
).run(principalId, userId, principalId);
});
reassign();
res.json({ ok: true, principal_id: principalId });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
// Empêcher la suppression du compte principal
const target = db
.prepare('SELECT id, is_principal FROM investisseurs WHERE id=? AND user_id=?')
.get(req.params.id, req.user.id);
if (!target) throw new HttpError(404, 'Not found');
if (target.is_principal) throw new HttpError(400, 'Impossible de supprimer le compte principal.');
// Empêcher la suppression si c'est le seul membre
const count = db
.prepare('SELECT COUNT(*) AS n FROM investisseurs WHERE user_id=?')
.get(req.user.id).n;
if (count <= 1) throw new HttpError(400, 'Impossible de supprimer le dernier profil.');
const r = db
.prepare('DELETE FROM investisseurs WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+135
View File
@@ -0,0 +1,135 @@
import { Router } from 'express';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const TYPES_VALIDES = ['etoiles', 'lettres', 'score', 'custom'];
/** Valide et normalise un critère entrant */
function parseBody(body) {
const { plateforme_id, nom, type = 'etoiles', valeurs, min_val, max_val, description, ordre } = body;
if (!plateforme_id) throw new HttpError(400, 'plateforme_id est requis');
if (!nom?.trim()) throw new HttpError(400, 'nom est requis');
if (!TYPES_VALIDES.includes(type)) throw new HttpError(400, `type invalide — attendu : ${TYPES_VALIDES.join(', ')}`);
// Validation selon le type
if (type === 'score') {
if (min_val === undefined || max_val === undefined) throw new HttpError(400, 'min_val et max_val requis pour le type score');
if (Number(min_val) >= Number(max_val)) throw new HttpError(400, 'min_val doit être inférieur à max_val');
}
if ((type === 'lettres' || type === 'custom') && (!valeurs || !valeurs.length)) {
throw new HttpError(400, `valeurs est requis pour le type ${type}`);
}
return {
plateforme_id: Number(plateforme_id),
nom: nom.trim(),
type,
valeurs: (type === 'lettres' || type === 'custom')
? JSON.stringify(Array.isArray(valeurs) ? valeurs : valeurs.split(',').map(v => v.trim()).filter(Boolean))
: null,
min_val: type === 'score' ? Number(min_val) : null,
max_val: type === 'score' ? Number(max_val) : null,
description: description?.trim() || null,
ordre: Number(ordre ?? 0),
};
}
/** Enrichit les lignes DB (parse valeurs JSON) */
function enrich(row) {
return {
...row,
valeurs: row.valeurs ? JSON.parse(row.valeurs) : null,
};
}
/* ── GET /api/notation?plateforme_id=X ───────────────────────── */
router.get('/', (req, res) => {
const { plateforme_id } = req.query;
if (plateforme_id) {
const rows = db.prepare(`
SELECT nc.* FROM notation_criteres nc
JOIN plateformes p ON p.id = nc.plateforme_id
WHERE nc.plateforme_id = ? AND p.user_id = ?
ORDER BY nc.ordre, nc.id
`).all(Number(plateforme_id), req.user.id);
return res.json(rows.map(enrich));
}
// Tous les critères de l'utilisateur (toutes plateformes)
const rows = db.prepare(`
SELECT nc.*, p.nom AS plateforme_nom FROM notation_criteres nc
JOIN plateformes p ON p.id = nc.plateforme_id
WHERE p.user_id = ?
ORDER BY p.nom, nc.ordre, nc.id
`).all(req.user.id);
res.json(rows.map(enrich));
});
/* ── POST /api/notation ──────────────────────────────────────── */
router.post('/', (req, res, next) => {
try {
const data = parseBody(req.body);
// Vérifie que la plateforme appartient à l'utilisateur
const plat = db.prepare('SELECT id FROM plateformes WHERE id = ? AND user_id = ?')
.get(data.plateforme_id, req.user.id);
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
const r = db.prepare(`
INSERT INTO notation_criteres (plateforme_id, nom, type, valeurs, min_val, max_val, description, ordre)
VALUES (?,?,?,?,?,?,?,?)
`).run(data.plateforme_id, data.nom, data.type, data.valeurs, data.min_val, data.max_val, data.description, data.ordre);
const created = enrich(db.prepare('SELECT * FROM notation_criteres WHERE id = ?').get(r.lastInsertRowid));
res.status(201).json(created);
} catch (e) { next(e); }
});
/* ── PUT /api/notation/:id ───────────────────────────────────── */
router.put('/:id', (req, res, next) => {
try {
const existing = db.prepare(`
SELECT nc.* FROM notation_criteres nc
JOIN plateformes p ON p.id = nc.plateforme_id
WHERE nc.id = ? AND p.user_id = ?
`).get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Critère introuvable');
const data = parseBody({ ...req.body, plateforme_id: existing.plateforme_id });
db.prepare(`
UPDATE notation_criteres
SET nom=?, type=?, valeurs=?, min_val=?, max_val=?, description=?, ordre=?
WHERE id=?
`).run(data.nom, data.type, data.valeurs, data.min_val, data.max_val, data.description, data.ordre, req.params.id);
res.json(enrich(db.prepare('SELECT * FROM notation_criteres WHERE id = ?').get(req.params.id)));
} catch (e) { next(e); }
});
/* ── DELETE /api/notation/:id ────────────────────────────────── */
router.delete('/:id', (req, res, next) => {
try {
const existing = db.prepare(`
SELECT nc.id FROM notation_criteres nc
JOIN plateformes p ON p.id = nc.plateforme_id
WHERE nc.id = ? AND p.user_id = ?
`).get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Critère introuvable');
db.prepare('DELETE FROM notation_criteres WHERE id = ?').run(req.params.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
/* ── PUT /api/notation/reorder ───────────────────────────────── */
router.put('/reorder', (req, res, next) => {
try {
const { ids } = req.body; // tableau d'ids dans le nouvel ordre
if (!Array.isArray(ids)) throw new HttpError(400, 'ids[] requis');
const upd = db.prepare('UPDATE notation_criteres SET ordre = ? WHERE id = ?');
const tx = db.transaction(() => ids.forEach((id, i) => upd.run(i, id)));
tx();
res.json({ reordered: true });
} catch (e) { next(e); }
});
export default router;
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
/* GET /api/pfu — liste triée par année */
router.get('/', (req, res) => {
const rows = db.prepare('SELECT * FROM taux_pfu ORDER BY annee ASC').all();
res.json(rows);
});
/* POST /api/pfu — ajouter une année */
router.post('/', (req, res) => {
const { annee, impot_revenu, csg, crds, solidarite } = req.body;
if (!annee || impot_revenu == null || csg == null || crds == null || solidarite == null) {
return res.status(400).json({ error: 'Champs requis : annee, impot_revenu, csg, crds, solidarite' });
}
const prelev_sociaux = Number(csg) + Number(crds) + Number(solidarite);
const pfu_total = Number(impot_revenu) + prelev_sociaux;
try {
const stmt = db.prepare(
'INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux, csg, crds, solidarite) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux,
Number(csg), Number(crds), Number(solidarite)
);
res.status(201).json(db.prepare('SELECT * FROM taux_pfu WHERE id = ?').get(result.lastInsertRowid));
} catch (e) {
if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: `L'année ${annee} existe déjà.` });
throw e;
}
});
/* PUT /api/pfu/:id — modifier */
router.put('/:id', (req, res) => {
const { annee, impot_revenu, csg, crds, solidarite } = req.body;
if (!annee || impot_revenu == null || csg == null || crds == null || solidarite == null) {
return res.status(400).json({ error: 'Champs requis : annee, impot_revenu, csg, crds, solidarite' });
}
const prelev_sociaux = Number(csg) + Number(crds) + Number(solidarite);
const pfu_total = Number(impot_revenu) + prelev_sociaux;
try {
db.prepare(
`UPDATE taux_pfu SET annee=?, pfu_total=?, impot_revenu=?, prelev_sociaux=?,
csg=?, crds=?, solidarite=?, updated_at=datetime('now') WHERE id=?`
).run(
Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux,
Number(csg), Number(crds), Number(solidarite), Number(req.params.id)
);
const row = db.prepare('SELECT * FROM taux_pfu WHERE id = ?').get(Number(req.params.id));
if (!row) return res.status(404).json({ error: 'Introuvable' });
res.json(row);
} catch (e) {
if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: `L'année ${annee} existe déjà.` });
throw e;
}
});
/* DELETE /api/pfu/:id */
router.delete('/:id', (req, res) => {
db.prepare('DELETE FROM taux_pfu WHERE id = ?').run(Number(req.params.id));
res.status(204).end();
});
export default router;
+70
View File
@@ -0,0 +1,70 @@
import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
/* ── GET /api/plateforme-tax/:platId/:annee
Retourne l'enregistrement pour l'année.
S'il n'existe pas, clone depuis N-1 (ou crée vide). ── */
router.get('/:platId/:annee', (req, res) => {
const platId = Number(req.params.platId);
const annee = Number(req.params.annee);
// Vérifier que la plateforme appartient à l'utilisateur
const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?').get(platId, req.user.id);
if (!plat) return res.status(404).json({ error: 'Plateforme introuvable' });
let row = db.prepare('SELECT * FROM plateforme_tax_details WHERE plateforme_id = ? AND annee = ?').get(platId, annee);
if (!row) {
// Chercher l'année précédente
const prev = db.prepare(
'SELECT * FROM plateforme_tax_details WHERE plateforme_id = ? AND annee < ? ORDER BY annee DESC LIMIT 1'
).get(platId, annee);
const raison_sociale = prev?.raison_sociale ?? plat.nom;
const siret_n = prev?.siret_n ?? null;
// siret_n1 = le siret_n de l'année précédente (s'il a changé entre N-1 et N, on le garde)
const siret_n1 = prev?.siret_n ?? null;
const r = db.prepare(
'INSERT INTO plateforme_tax_details (plateforme_id, annee, raison_sociale, siret_n, siret_n1) VALUES (?,?,?,?,?)'
).run(platId, annee, raison_sociale, siret_n, siret_n1);
row = db.prepare('SELECT * FROM plateforme_tax_details WHERE id = ?').get(r.lastInsertRowid);
}
res.json(row);
});
/* ── PATCH /api/plateforme-tax/:id — mise à jour des champs ── */
router.patch('/:id', (req, res) => {
const id = Number(req.params.id);
const { raison_sociale, siret_n, siret_n1 } = req.body;
// Vérifier l'appartenance via la plateforme
const row = db.prepare(`
SELECT ptd.id FROM plateforme_tax_details ptd
JOIN plateformes p ON p.id = ptd.plateforme_id
WHERE ptd.id = ? AND p.user_id = ?
`).get(id, req.user.id);
if (!row) return res.status(404).json({ error: 'Enregistrement introuvable' });
db.prepare(`
UPDATE plateforme_tax_details
SET raison_sociale = COALESCE(?, raison_sociale),
siret_n = ?,
siret_n1 = ?
WHERE id = ?
`).run(
raison_sociale !== undefined ? raison_sociale : null,
siret_n !== undefined ? siret_n : null,
siret_n1 !== undefined ? siret_n1 : null,
id
);
res.json(db.prepare('SELECT * FROM plateforme_tax_details WHERE id = ?').get(id));
});
export default router;
+693
View File
@@ -0,0 +1,693 @@
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 refTags = refCatMap[r.referentiel_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 refTags = refSectMap[r.referentiel_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.domiciliation, pr.fiscalite,
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename
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,
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);
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;
// Si un référentiel est sélectionné, hériter ses valeurs (pas encore d'overrides)
if (referentielId) {
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentielId);
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
// Les champs du body priment (l'user peut déjà avoir modifié à la création)
// On calcule les overrides par rapport au référentiel
}
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,
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,
referentielId,
'[]' // toujours vide à la création — les overrides se calculent au premier PUT
);
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
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.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: rows.length, type: 'plateformes' }, 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,
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) });
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
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,
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) });
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');
// Résoudre investisseur par nom+prénom
const userInvestisseurs = db.prepare('SELECT * FROM investisseurs WHERE user_id = ?').all(req.user.id);
function resolveInvestisseur(nom, prenom) {
if (!nom) return userInvestisseurs[0]?.id ?? null;
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);
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); }
});
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');
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); }
});
// ── 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=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, logo_filename=?, overridden_fields='[]'
WHERE id=? AND user_id=?
`).run(
ref.nom, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null,
ref.type_produit_fiscal, ref.logo_filename ?? 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;
+62
View File
@@ -0,0 +1,62 @@
import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
// Clés autorisées — liste blanche pour éviter les injections de clés arbitraires
const ALLOWED_KEYS = new Set([
'chart_interets',
'chart_capital',
'chart_cashback',
// Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…)
]);
/**
* GET /api/preferences
* Retourne toutes les préférences de l'utilisateur connecté sous forme d'objet plat.
* Les clés absentes ne sont pas retournées (le frontend utilise ses valeurs par défaut).
*/
router.get('/', (req, res) => {
const rows = db
.prepare('SELECT key, value FROM user_preferences WHERE user_id = ?')
.all(req.user.id);
const result = {};
for (const { key, value } of rows) result[key] = value;
res.json(result);
});
/**
* PATCH /api/preferences
* Upsert d'un ou plusieurs couples { key: value }.
* Seules les clés de ALLOWED_KEYS sont acceptées.
*/
router.patch('/', (req, res) => {
const payload = req.body;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return res.status(400).json({ error: 'Corps invalide — objet { clé: valeur } attendu' });
}
const upsert = db.prepare(`
INSERT INTO user_preferences (user_id, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`);
const unknown = Object.keys(payload).filter(k => !ALLOWED_KEYS.has(k));
if (unknown.length) {
return res.status(400).json({ error: `Clés inconnues : ${unknown.join(', ')}` });
}
const persist = db.transaction(() => {
for (const [key, value] of Object.entries(payload)) {
upsert.run(req.user.id, key, String(value));
}
});
persist();
res.json({ ok: true });
});
export default router;
+97
View File
@@ -0,0 +1,97 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
/* GET /api/ref-categories — liste complète */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT c.id, c.nom,
COUNT(rc.referentiel_id) AS nb_utilises
FROM categories_inv c
LEFT JOIN referentiel_categories_inv rc ON rc.categorie_id = c.id
GROUP BY c.id
ORDER BY c.nom
`).all();
res.json(rows);
});
/* POST /api/ref-categories — créer */
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const exists = db.prepare('SELECT id FROM categories_inv WHERE nom = ?').get(nom.trim());
if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
const r = db.prepare('INSERT INTO categories_inv (nom) VALUES (?)').run(nom.trim());
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 });
} catch (e) { next(e); }
});
/* PUT /api/ref-categories/:id — renommer (propage automatiquement) */
router.put('/:id', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const row = db.prepare('SELECT id, nom FROM categories_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const dup = db.prepare('SELECT id FROM categories_inv WHERE nom = ? AND id != ?').get(nom.trim(), row.id);
if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
db.prepare('UPDATE categories_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_inv WHERE categorie_id = ?').get(row.id).n;
res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb });
} catch (e) { next(e); }
});
/* DELETE /api/ref-categories/:id — supprimer si non utilisée */
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM categories_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_inv WHERE categorie_id = ?').get(row.id).n;
if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} référentiel(s) — retirez-la d'abord.`);
db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id);
res.status(204).end();
} catch (e) { next(e); }
});
/* POST /api/ref-categories/:id/merge — fusionner dans une autre catégorie */
router.post('/:id/merge', (req, res, next) => {
try {
const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body);
const source = db.prepare('SELECT id, nom FROM categories_inv WHERE id = ?').get(req.params.id);
if (!source) throw new HttpError(404, 'Catégorie source introuvable');
const target = db.prepare('SELECT id, nom FROM categories_inv WHERE id = ?').get(target_id);
if (!target) throw new HttpError(404, 'Catégorie cible introuvable');
if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques');
const result = db.transaction(() => {
const refs = db.prepare(
'SELECT referentiel_id FROM referentiel_categories_inv WHERE categorie_id = ?'
).all(source.id);
let added = 0, skipped = 0;
for (const { referentiel_id } of refs) {
const exists = db.prepare(
'SELECT 1 FROM referentiel_categories_inv WHERE referentiel_id = ? AND categorie_id = ?'
).get(referentiel_id, target.id);
if (!exists) {
db.prepare(
'INSERT INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?, ?)'
).run(referentiel_id, target.id);
added++;
} else {
skipped++;
}
}
db.prepare('DELETE FROM referentiel_categories_inv WHERE categorie_id = ?').run(source.id);
db.prepare('DELETE FROM categories_inv WHERE id = ?').run(source.id);
return { added, skipped, total: refs.length };
})();
res.json({ ok: true, ...result,
message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` });
} catch (e) { next(e); }
});
export default router;
+97
View File
@@ -0,0 +1,97 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
/* GET /api/ref-secteurs — liste complète */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT s.id, s.nom,
COUNT(rs.referentiel_id) AS nb_utilises
FROM secteurs_inv s
LEFT JOIN referentiel_secteurs_inv rs ON rs.secteur_id = s.id
GROUP BY s.id
ORDER BY s.nom
`).all();
res.json(rows);
});
/* POST /api/ref-secteurs — créer */
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const exists = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ?').get(nom.trim());
if (exists) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`);
const r = db.prepare('INSERT INTO secteurs_inv (nom) VALUES (?)').run(nom.trim());
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 });
} catch (e) { next(e); }
});
/* PUT /api/ref-secteurs/:id — renommer */
router.put('/:id', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const row = db.prepare('SELECT id, nom FROM secteurs_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Secteur introuvable');
const dup = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ? AND id != ?').get(nom.trim(), row.id);
if (dup) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`);
db.prepare('UPDATE secteurs_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_secteurs_inv WHERE secteur_id = ?').get(row.id).n;
res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb });
} catch (e) { next(e); }
});
/* DELETE /api/ref-secteurs/:id — supprimer si non utilisé */
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM secteurs_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Secteur introuvable');
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_secteurs_inv WHERE secteur_id = ?').get(row.id).n;
if (nb > 0) throw new HttpError(409, `Secteur utilisé par ${nb} référentiel(s) — retirez-le d'abord.`);
db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id);
res.status(204).end();
} catch (e) { next(e); }
});
/* POST /api/ref-secteurs/:id/merge — fusionner dans un autre secteur */
router.post('/:id/merge', (req, res, next) => {
try {
const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body);
const source = db.prepare('SELECT id, nom FROM secteurs_inv WHERE id = ?').get(req.params.id);
if (!source) throw new HttpError(404, 'Secteur source introuvable');
const target = db.prepare('SELECT id, nom FROM secteurs_inv WHERE id = ?').get(target_id);
if (!target) throw new HttpError(404, 'Secteur cible introuvable');
if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques');
const result = db.transaction(() => {
const refs = db.prepare(
'SELECT referentiel_id FROM referentiel_secteurs_inv WHERE secteur_id = ?'
).all(source.id);
let added = 0, skipped = 0;
for (const { referentiel_id } of refs) {
const exists = db.prepare(
'SELECT 1 FROM referentiel_secteurs_inv WHERE referentiel_id = ? AND secteur_id = ?'
).get(referentiel_id, target.id);
if (!exists) {
db.prepare(
'INSERT INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?, ?)'
).run(referentiel_id, target.id);
added++;
} else {
skipped++;
}
}
db.prepare('DELETE FROM referentiel_secteurs_inv WHERE secteur_id = ?').run(source.id);
db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(source.id);
return { added, skipped, total: refs.length };
})();
res.json({ ok: true, ...result,
message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` });
} catch (e) { next(e); }
});
export default router;
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
// Monté sous requireAuth (sans requireAdmin) — lecture seule pour tous les users
function attachCats(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const cats = db.prepare(`
SELECT rc.referentiel_id, rc.categorie_nom
FROM referentiel_categories rc
WHERE rc.referentiel_id IN (${ids.map(() => '?').join(',')})
ORDER BY rc.categorie_nom
`).all(...ids);
const map = {};
for (const c of cats) {
if (!map[c.referentiel_id]) map[c.referentiel_id] = [];
map[c.referentiel_id].push(c.categorie_nom);
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
function attachNotation(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const notations = db.prepare(`
SELECT * FROM referentiel_notation
WHERE referentiel_id IN (${ids.map(() => '?').join(',')})
ORDER BY referentiel_id, ordre, id
`).all(...ids);
const map = {};
for (const n of notations) {
if (!map[n.referentiel_id]) map[n.referentiel_id] = [];
map[n.referentiel_id].push({ ...n, valeurs: n.valeurs ? JSON.parse(n.valeurs) : null });
}
return rows.map(r => ({ ...r, notation: map[r.id] || [] }));
}
function parsePaysOp(rows) {
return rows.map(r => ({
...r,
pays_operation: r.pays_operation
? (typeof r.pays_operation === 'string' ? JSON.parse(r.pays_operation) : r.pays_operation)
: [],
}));
}
// ── GET /api/referentiel-public/:id ───────────────────────────────────────────
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Référentiel introuvable');
const [enriched] = parsePaysOp(attachNotation(attachCats([row])));
res.json(enriched);
} catch (e) { next(e); }
});
export default router;
+89
View File
@@ -0,0 +1,89 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimulWithReinvestissements } from '../utils/schedule.js';
const router = Router();
router.use(requireInvestisseur);
const Schema = z.object({
investissement_id: z.number().int().positive(),
montant: z.number().positive(),
date_reinvestissement: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
note: z.string().optional().nullable(),
});
/** Vérifie que l'investissement appartient bien à l'utilisateur connecté. */
function checkOwnership(investissementId, userId) {
const row = db.prepare(`
SELECT i.id FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.id = ?
`).get(userId, investissementId);
if (!row) throw new HttpError(404, 'Investissement introuvable');
return row;
}
// GET /api/reinvestissements?investissement_id=X (un investissement)
// GET /api/reinvestissements?scope=all (tous les investissements de l'utilisateur)
router.get('/', (req, res, next) => {
try {
if (req.query.scope === 'all') {
const rows = db.prepare(`
SELECT rv.*
FROM reinvestissements rv
JOIN investissements i ON i.id = rv.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
ORDER BY rv.date_reinvestissement
`).all(req.user.id);
return res.json(rows);
}
const invId = Number(req.query.investissement_id);
if (!invId) throw new HttpError(400, 'investissement_id requis');
checkOwnership(invId, req.user.id);
const rows = db.prepare(
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(invId);
res.json(rows);
} catch (e) { next(e); }
});
// POST /api/reinvestissements
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
checkOwnership(body.investissement_id, req.user.id);
const r = db.prepare(`
INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note)
VALUES (?, ?, ?, ?)
`).run(body.investissement_id, body.montant, body.date_reinvestissement, body.note || null);
// Régénérer le tableau de simulation en tenant compte du réinvestissement
generateSimulWithReinvestissements(db, body.investissement_id);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
// DELETE /api/reinvestissements/:id
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM reinvestissements WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Réinvestissement introuvable');
checkOwnership(row.investissement_id, req.user.id);
db.prepare('DELETE FROM reinvestissements WHERE id = ?').run(req.params.id);
// Régénérer le tableau de simulation sans ce réinvestissement
generateSimulWithReinvestissements(db, row.investissement_id);
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+467
View File
@@ -0,0 +1,467 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js';
const router = Router();
const Schema = z.object({
type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme']).default('normal'),
investissement_id: z.number().int().positive().nullable().optional(),
bonus_plateforme_id: z.number().int().positive().nullable().optional(),
bonus_investisseur_id: z.number().int().positive().nullable().optional(),
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
capital: z.number().nonnegative().default(0),
cashback: z.number().nonnegative().default(0),
interets_bruts_avant_local: z.number().nonnegative().default(0),
taxe_locale: z.number().nonnegative().default(0),
interets_bruts: z.number().nonnegative().default(0),
prelev_sociaux: z.number().nonnegative().default(0),
prelev_forfaitaire: z.number().nonnegative().default(0),
statut: z.enum(['paye','retard','partiel','impaye']).default('paye'),
notes: z.string().optional(),
methode_remboursement: z.enum(['portefeuille', 'compte_courant']).default('portefeuille'),
compte_id: z.number().int().positive().nullable().optional(),
}).refine(data => {
if (data.type === 'normal') return !!data.investissement_id;
return !!data.bonus_plateforme_id;
}, { message: 'investissement_id requis pour type normal ; bonus_plateforme_id requis pour les bonus' });
/**
* isTaxIndicatif = true → plateforme étrangère ou investissement exonéré PEA-PME :
* les prélèvements sont stockés à titre indicatif, mais le montant réellement
* versé (net_recu) n'en tient pas compte (capital + cashback + interets_bruts).
* isTaxIndicatif = false → plateforme flat_tax non exonérée :
* les prélèvements sont retenus à la source, net_recu = capital + cashback + interets_nets.
*/
function computeChamps(body, isTaxIndicatif = false) {
const interets_nets = Math.round((body.interets_bruts - body.prelev_sociaux - body.prelev_forfaitaire) * 100) / 100;
const net_recu = isTaxIndicatif
? Math.round(((body.capital || 0) + (body.cashback || 0) + (body.interets_bruts || 0)) * 100) / 100
: Math.round(((body.capital || 0) + (body.cashback || 0) + interets_nets) * 100) / 100;
return { interets_nets, net_recu };
}
function getTaxIndicatif(investissementId) {
if (!investissementId) return false;
const row = db.prepare(`
SELECT p.fiscalite, i.fiscalite_override
FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.id = ?
`).get(investissementId);
if (!row) return false;
return row.fiscalite !== 'flat_tax' || row.fiscalite_override === 'exonere';
}
/**
* Crée ou supprime le retrait automatique lié à un remboursement.
* Appelé après chaque POST/PUT sur un remboursement normal.
* - Si methode === 'compte_courant' : supprime l'ancien retrait lié (s'il existe)
* puis en insère un nouveau avec source='auto_remboursement'.
* - Sinon : supprime simplement l'ancien retrait lié.
*/
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
// Suppression du retrait automatique précédent (s'il y en avait un)
db.prepare('DELETE FROM depots_retraits WHERE remboursement_id = ?').run(rembId);
if (methode !== 'compte_courant') return;
const inv = db.prepare(
'SELECT investisseur_id, plateforme_id, nom_projet FROM investissements WHERE id = ?'
).get(investissementId);
if (!inv) return;
db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant,
libelle, source, remboursement_id)
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?)
`).run(
inv.investisseur_id, inv.plateforme_id, dateRemb,
netRecu,
`Remboursement — ${inv.nom_projet}`,
rembId,
);
}
function syncInvestissementStatut(investissement_id) {
if (!investissement_id) return;
const inv = db.prepare('SELECT montant_investi FROM investissements WHERE id=?').get(investissement_id);
if (!inv) return;
const { total_capital } = db.prepare(
'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id=?'
).get(investissement_id);
const { reinvTotal } = db.prepare(
'SELECT COALESCE(SUM(montant), 0) AS reinvTotal FROM reinvestissements WHERE investissement_id=?'
).get(investissement_id);
const capitalTotal = inv.montant_investi + (reinvTotal || 0);
const restant = Math.round((capitalTotal - total_capital) * 100) / 100;
const newStatut = restant <= 0 ? 'rembourse' : 'en_cours';
db.prepare(`
UPDATE investissements
SET statut = ?, updated_at = datetime('now')
WHERE id = ? AND statut IN ('en_cours', 'rembourse')
`).run(newStatut, investissement_id);
}
router.use(requireInvestisseur);
function assertOwnedInvestissement(invId, userId) {
const row = db.prepare(`
SELECT i.id FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.id = ?
`).get(userId, invId);
if (!row) throw new HttpError(403, 'Investissement not in scope');
}
function assertOwnedInvestisseur(invId, userId) {
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) throw new HttpError(403, 'Investisseur not in scope');
}
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const { from, to, plateforme_id, investissement_id } = req.query;
const invCond = scopeAll
? `(i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
OR (r.investissement_id IS NULL AND r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)))`
: `(i.investisseur_id = ?
OR (r.investissement_id IS NULL AND r.bonus_investisseur_id = ?))`;
const invArg = scopeAll ? req.user.id : req.investisseur.id;
const outerConds = [];
const outerArgs = [];
if (from) { outerConds.push('date_remb >= ?'); outerArgs.push(from); }
if (to) { outerConds.push('date_remb <= ?'); outerArgs.push(to); }
if (plateforme_id) { outerConds.push('plateforme_id = ?'); outerArgs.push(Number(plateforme_id)); }
if (investissement_id){ outerConds.push('investissement_id = ?'); outerArgs.push(Number(investissement_id)); }
const outerWhere = outerConds.length ? `WHERE ${outerConds.join(' AND ')}` : '';
const rows = db.prepare(`
WITH base AS (
SELECT r.*,
COALESCE(i.plateforme_id, r.bonus_plateforme_id) AS plateforme_id,
COALESCE(i.investisseur_id, r.bonus_investisseur_id) AS resolved_investisseur_id,
i.nom_projet AS inv_nom_projet,
CASE WHEN r.investissement_id IS NOT NULL
THEN i.montant_investi - (
SELECT COALESCE(SUM(r2.capital), 0)
FROM remboursements r2
WHERE r2.investissement_id = r.investissement_id
AND (r2.date_remb < r.date_remb OR (r2.date_remb = r.date_remb AND r2.id <= r.id))
)
ELSE 0
END AS capital_restant_du
FROM remboursements r
LEFT JOIN investissements i ON i.id = r.investissement_id
WHERE ${invCond}
)
SELECT
b.*,
CASE b.type
WHEN 'normal' THEN b.inv_nom_projet
WHEN 'bonus_parrainage' THEN '— Bonus Parrainage'
WHEN 'bonus_plateforme' THEN '— Bonus Plateforme'
ELSE b.inv_nom_projet
END AS nom_projet,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
plat_inv.nom AS plateforme_detenteur_nom,
c.nom AS compte_nom
FROM base b
LEFT JOIN plateformes p ON p.id = b.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
LEFT JOIN investisseurs inv ON inv.id = b.resolved_investisseur_id
LEFT JOIN comptes c ON c.id = b.compte_id
${outerWhere}
ORDER BY b.date_remb DESC, b.id DESC
`).all(invArg, invArg, ...outerArgs);
res.json(rows);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id));
if (body.type === 'normal') {
assertOwnedInvestissement(body.investissement_id, req.user.id);
const r = db.prepare(`
INSERT INTO remboursements
(type, investissement_id, date_remb, capital, cashback,
interets_bruts_avant_local, taxe_locale, interets_bruts, prelev_sociaux,
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id)
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?)
`).run(
body.investissement_id, body.date_remb, body.capital, body.cashback,
body.interets_bruts_avant_local, body.taxe_locale,
body.interets_bruts, body.prelev_sociaux, body.prelev_forfaitaire,
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
);
syncInvestissementStatut(body.investissement_id);
adjustSimulForActuals(db, body.investissement_id);
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
// ── Réinvestissement automatique des intérêts ──────────────────────────
{
const inv = db.prepare(
'SELECT i.auto_reinvest, p.fiscalite FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id WHERE i.id = ?'
).get(body.investissement_id);
if (inv?.auto_reinvest) {
// Plateforme française (flat_tax) : utiliser les intérêts nets (prélevés à la source)
// Autres plateformes : utiliser les intérêts bruts (rien n'est retenu)
const montantAuto = inv.fiscalite === 'flat_tax' ? interets_nets : body.interets_bruts;
if (montantAuto > 0) {
db.prepare(`
INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source)
VALUES (?, ?, ?, 'Réinvestissement automatique des intérêts', 'auto')
`).run(body.investissement_id, montantAuto, body.date_remb);
generateSimulWithReinvestissements(db, body.investissement_id);
}
}
}
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
}
// Bonus (parrainage ou plateforme)
const bonusInvestisseurId = body.bonus_investisseur_id ?? req.investisseur.id;
assertOwnedInvestisseur(bonusInvestisseurId, req.user.id);
const r = db.prepare(`
INSERT INTO remboursements
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire,
interets_nets, net_recu, statut, source, notes)
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?)
`).run(
body.type, body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
body.cashback, body.cashback, body.statut, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets: 0, net_recu: body.cashback });
} catch (e) { next(e); }
});
/* ── Retraitement fiscal en masse ───────────────────────────── */
router.post('/reprocess', (req, res, next) => {
try {
const round2 = v => Math.round(v * 100) / 100;
const rembs = db.prepare(`
SELECT r.id, r.investissement_id, r.capital, r.cashback, r.date_remb,
r.interets_bruts_avant_local, r.taxe_locale, r.interets_bruts,
p.fiscalite, p.taux_fiscalite_locale,
i.fiscalite_override
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE r.type = 'normal'
AND r.investissement_id IS NOT NULL
AND inv.user_id = ?
`).all(req.user.id);
const pfuRates = db.prepare('SELECT * FROM taux_pfu').all();
const getRates = (dateStr) => {
const year = parseInt(dateStr.substring(0, 4), 10);
return pfuRates.find(r => r.annee === year) ?? null;
};
const stmtUpdate = db.prepare(`
UPDATE remboursements
SET taxe_locale=?, interets_bruts=?, prelev_sociaux=?, prelev_forfaitaire=?,
interets_nets=?, net_recu=?
WHERE id=?
`);
const stmtUpdateRetrait = db.prepare(`
UPDATE depots_retraits SET montant=?
WHERE remboursement_id=? AND source='auto_remboursement'
`);
let updated = 0;
const affectedInvIds = new Set();
db.transaction(() => {
for (const r of rembs) {
const rates = getRates(r.date_remb);
let taxe_locale = 0;
let interets_bruts = r.interets_bruts;
let prelev_sociaux = 0;
let prelev_forfaitaire = 0;
if (r.fiscalite === 'avec_fiscalite_locale' && r.taux_fiscalite_locale) {
// Base = interets avant taxe locale (fallback sur interets_bruts si colonne vide)
const base = (r.interets_bruts_avant_local > 0)
? r.interets_bruts_avant_local
: r.interets_bruts;
taxe_locale = round2(base * r.taux_fiscalite_locale / 100);
interets_bruts = round2(base - taxe_locale);
}
// Prélèvements calculés pour TOUTES les plateformes (indicatif pour les non-flat_tax)
if (rates) {
prelev_sociaux = round2(interets_bruts * rates.prelev_sociaux / 100);
prelev_forfaitaire = round2(interets_bruts * rates.impot_revenu / 100);
}
const interets_nets = round2(interets_bruts - prelev_sociaux - prelev_forfaitaire);
// Plateforme étrangère ou investissement exonéré : le montant versé n'inclut pas
// les prélèvements indicatifs — l'investisseur reçoit le brut intégral.
const isTaxIndicatif = r.fiscalite !== 'flat_tax' || r.fiscalite_override === 'exonere';
const net_recu = isTaxIndicatif
? round2((r.capital || 0) + (r.cashback || 0) + interets_bruts)
: round2((r.capital || 0) + (r.cashback || 0) + interets_nets);
stmtUpdate.run(taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire,
interets_nets, net_recu, r.id);
stmtUpdateRetrait.run(net_recu, r.id);
affectedInvIds.add(r.investissement_id);
updated++;
}
})();
// Régénérer l'échéancier pour chaque investissement impacté
for (const invId of affectedInvIds) {
adjustSimulForActuals(db, invId);
}
res.json({ updated });
} catch (e) { next(e); }
});
/* ── Modifier un remboursement ──────────────────────────────── */
router.put('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare(`
SELECT r.id, r.type, r.investissement_id, r.bonus_plateforme_id, r.bonus_investisseur_id
FROM remboursements r
LEFT JOIN investissements i ON i.id = r.investissement_id
LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id)
WHERE r.id = ? AND inv.user_id = ?
`).get(id, req.user.id);
if (!existing) throw new HttpError(404, 'Not found');
const body = Schema.parse(req.body);
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id));
if (body.type === 'normal') {
db.prepare(`
UPDATE remboursements
SET date_remb=?, capital=?, cashback=?,
interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?,
prelev_sociaux=?, prelev_forfaitaire=?,
interets_nets=?, net_recu=?, statut=?, notes=?, methode_remboursement=?, compte_id=?
WHERE id=?
`).run(
body.date_remb, body.capital, body.cashback,
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
body.prelev_sociaux, body.prelev_forfaitaire,
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
id,
);
syncInvestissementStatut(body.investissement_id);
adjustSimulForActuals(db, body.investissement_id);
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
} else {
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
db.prepare(`
UPDATE remboursements
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?
WHERE id=?
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, id);
}
res.json({ id, ...body, interets_nets, net_recu });
} catch (e) { next(e); }
});
/* ── Supprimer un remboursement ─────────────────────────────── */
router.delete('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare(`
SELECT r.id, r.investissement_id
FROM remboursements r
LEFT JOIN investissements i ON i.id = r.investissement_id
LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id)
WHERE r.id = ? AND inv.user_id = ?
`).get(id, req.user.id);
if (!existing) throw new HttpError(404, 'Not found');
syncAutoRetrait(id, 'portefeuille', 0, null, null); // supprime le retrait auto si présent
db.prepare('DELETE FROM remboursements WHERE id=?').run(id);
if (existing.investissement_id) {
syncInvestissementStatut(existing.investissement_id);
adjustSimulForActuals(db, existing.investissement_id);
}
res.json({ deleted: id });
} catch (e) { next(e); }
});
/* ── Supprimer tous les remboursements d'un investissement ───── */
router.delete('/', (req, res, next) => {
try {
const investissement_id = Number(req.query.investissement_id);
if (!investissement_id) throw new HttpError(400, 'investissement_id requis');
assertOwnedInvestissement(investissement_id, req.user.id);
const rembs = db.prepare('SELECT id FROM remboursements WHERE investissement_id=?').all(investissement_id);
for (const r of rembs) {
syncAutoRetrait(r.id, 'portefeuille', 0, null, null);
}
db.prepare('DELETE FROM remboursements WHERE investissement_id=?').run(investissement_id);
syncInvestissementStatut(investissement_id);
adjustSimulForActuals(db, investissement_id);
res.json({ deleted: rembs.length });
} catch (e) { next(e); }
});
// ── Backfill compte_id sur les remboursements ──────────────────────────────
// Pour chaque remboursement methode=compte_courant sans compte_id :
// 1. Utilise le compte_id de l'investissement lié si disponible
// 2. Sinon prend le premier compte de type compte_courant du détenteur
router.post('/backfill-comptes', (req, res, next) => {
try {
const rows = db.prepare(`
SELECT r.id AS remb_id, i.compte_id AS inv_compte_id, i.investisseur_id
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE r.methode_remboursement = 'compte_courant'
AND r.compte_id IS NULL
AND r.type = 'normal'
`).all(req.user.id);
let updated = 0;
const stmt = db.prepare('UPDATE remboursements SET compte_id=? WHERE id=?');
for (const row of rows) {
let compteId = row.inv_compte_id ?? null;
// Fallback : premier compte_courant du détenteur
if (!compteId) {
const compte = db.prepare(
"SELECT id FROM comptes WHERE investisseur_id=? AND user_id=? AND type='compte_courant' ORDER BY id LIMIT 1"
).get(row.investisseur_id, req.user.id);
compteId = compte?.id ?? null;
}
if (compteId) {
stmt.run(compteId, row.remb_id);
updated++;
}
}
res.json({ updated, total: rows.length });
} catch (e) { next(e); }
});
export default router;
+100
View File
@@ -0,0 +1,100 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
// Monté sous requireAuth dans server.js
/* ─────────────────────────────────────────────────────────────
GET /api/secteurs-inv
Retourne les secteurs globaux (user_id IS NULL) + privés
de l'utilisateur connecté.
───────────────────────────────────────────────────────────────*/
router.get('/', (req, res) => {
const userId = req.user.id;
const rows = db.prepare(`
SELECT
s.id,
s.nom,
CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global,
(SELECT COUNT(*) FROM plateforme_secteurs_inv ps WHERE ps.secteur_id = s.id
AND ps.plateforme_id IN (
SELECT p.id FROM plateformes p
JOIN investisseurs i ON i.id = p.investisseur_id
WHERE i.user_id = ?
)
) AS nb_plateformes,
(SELECT COUNT(*) FROM investissement_secteurs_inv is2 WHERE is2.secteur_id = s.id
AND is2.investissement_id IN (
SELECT inv.id FROM investissements inv
JOIN investisseurs i ON i.id = inv.investisseur_id
WHERE i.user_id = ?
)
) AS nb_investissements
FROM secteurs_inv s
WHERE s.user_id IS NULL OR s.user_id = ?
ORDER BY is_global DESC, s.nom
`).all(userId, userId, userId);
res.json(rows);
});
/* ─────────────────────────────────────────────────────────────
POST /api/secteurs-inv — créer un secteur privé
───────────────────────────────────────────────────────────────*/
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const userId = req.user.id;
const dup = db.prepare(
'SELECT id FROM secteurs_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?)'
).get(nom.trim(), userId);
if (dup) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`);
const r = db.prepare(
'INSERT INTO secteurs_inv (nom, user_id) VALUES (?, ?)'
).run(nom.trim(), userId);
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), is_global: 0, nb_plateformes: 0, nb_investissements: 0 });
} catch (e) { next(e); }
});
/* ─────────────────────────────────────────────────────────────
PUT /api/secteurs-inv/:id — renommer (uniquement les privés)
───────────────────────────────────────────────────────────────*/
router.put('/:id', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const userId = req.user.id;
const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Secteur introuvable');
if (row.user_id === null) throw new HttpError(403, 'Les secteurs globaux ne peuvent pas être modifiés.');
if (row.user_id !== userId) throw new HttpError(403, 'Ce secteur ne vous appartient pas.');
const dup = db.prepare(
'SELECT id FROM secteurs_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?) AND id != ?'
).get(nom.trim(), userId, row.id);
if (dup) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`);
db.prepare('UPDATE secteurs_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
res.json({ id: row.id, nom: nom.trim(), is_global: 0 });
} catch (e) { next(e); }
});
/* ─────────────────────────────────────────────────────────────
DELETE /api/secteurs-inv/:id — supprimer (uniquement les privés)
───────────────────────────────────────────────────────────────*/
router.delete('/:id', (req, res, next) => {
try {
const userId = req.user.id;
const row = db.prepare('SELECT id, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Secteur introuvable');
if (row.user_id === null) throw new HttpError(403, 'Les secteurs globaux ne peuvent pas être supprimés.');
if (row.user_id !== userId) throw new HttpError(403, 'Ce secteur ne vous appartient pas.');
db.transaction(() => {
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE secteur_id = ?').run(row.id);
db.prepare('DELETE FROM investissement_secteurs_inv WHERE secteur_id = ?').run(row.id);
db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id);
})();
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+171
View File
@@ -0,0 +1,171 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { buildSchedule, adjustSimulForActuals } from '../utils/schedule.js';
const router = Router();
const Schema = z.object({
investissement_id: z.number().int().positive(),
numero_echeance: z.number().int().positive(),
date_prevue: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
capital_prevu: z.number().nonnegative().default(0),
interets_prevus: z.number().nonnegative().default(0),
total_prevu: z.number().nonnegative().default(0),
notes: z.string().optional(),
});
/* GET /api/simul/all — toutes les projections pour l'investisseur actif (ou scope=all) */
router.get('/all', (req, res) => {
const userId = req.user.id;
const scopeAll = req.query.scope === 'all';
const rawInvId = req.header('X-Investisseur-Id');
const invId = Number(rawInvId);
let rows;
if (scopeAll) {
rows = db.prepare(`
SELECT s.*, i.plateforme_id, i.investisseur_id
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE inv.user_id = ?
ORDER BY s.date_prevue
`).all(userId);
} else {
if (!invId) return res.status(400).json({ error: 'Missing X-Investisseur-Id' });
const ok = db.prepare('SELECT id FROM investisseurs WHERE id=? AND user_id=?').get(invId, userId);
if (!ok) return res.status(403).json({ error: 'Investisseur not owned by user' });
rows = db.prepare(`
SELECT s.*, i.plateforme_id, i.investisseur_id
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE i.investisseur_id = ?
ORDER BY s.date_prevue
`).all(invId);
}
res.json(rows);
});
router.use(requireInvestisseur);
function assertOwnedInvestissement(invId, userId) {
const row = db.prepare(`
SELECT i.id, i.montant_investi, i.taux_interet, i.duree_mois, i.type_remb,
i.freq_interets, i.date_premiere_echeance, i.date_souscription
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, userId);
if (!row) throw new HttpError(403, 'Investissement not in scope');
return row;
}
router.get('/', (req, res) => {
const { investissement_id } = req.query;
if (!investissement_id) {
return res.status(400).json({ error: 'investissement_id query param required' });
}
assertOwnedInvestissement(Number(investissement_id), req.user.id);
const rows = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance'
).all(investissement_id);
res.json(rows);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
assertOwnedInvestissement(body.investissement_id, req.user.id);
const r = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu, notes)
VALUES (?,?,?,?,?,?,?)
`).run(
body.investissement_id, body.numero_echeance, body.date_prevue,
body.capital_prevu, body.interets_prevus, body.total_prevu, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
/**
* Auto-generate the simulated repayment schedule for an investissement
* based on its montant, taux, durée, type_remb and freq_interets.
*
* Body: { investissement_id, replace?: boolean }
*/
router.post('/generate', (req, res, next) => {
try {
const { investissement_id, replace = true } = req.body;
if (!investissement_id) throw new HttpError(400, 'investissement_id required');
const inv = assertOwnedInvestissement(Number(investissement_id), req.user.id);
if (!inv.taux_interet || !inv.duree_mois) {
throw new HttpError(400, 'Investissement requires taux_interet and duree_mois');
}
const tx = db.transaction(() => {
if (replace) {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissement_id);
}
const start = inv.date_premiere_echeance || inv.date_souscription;
const echeances = buildSchedule({
montant: inv.montant_investi,
taux: inv.taux_interet,
duree: inv.duree_mois,
type: inv.type_remb || 'in_fine',
freq: inv.freq_interets || 'mensuel',
startDate: start,
});
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of echeances) {
stmt.run(investissement_id, e.n, e.date, e.capital, e.interets, e.total);
}
return echeances.length;
});
const inserted = tx();
res.status(201).json({ inserted });
} catch (e) { next(e); }
});
/**
* Recalcule l'échéancier en tenant compte des remboursements réels enregistrés
* (remboursements anticipés + première période partielle).
* Équivalent à ce qui est déclenché automatiquement après chaque saisie de remboursement.
*
* Body: { investissement_id }
*/
router.post('/recalculate', (req, res, next) => {
try {
const { investissement_id } = req.body;
if (!investissement_id) throw new HttpError(400, 'investissement_id required');
assertOwnedInvestissement(Number(investissement_id), req.user.id);
adjustSimulForActuals(db, Number(investissement_id));
const rows = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance'
).all(investissement_id);
res.json({ recalculated: rows.length, rows });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const owns = db.prepare(`
SELECT s.id FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE s.id=? AND i.investisseur_id=?
`).get(req.params.id, req.investisseur.id);
if (!owns) throw new HttpError(404, 'Not found');
db.prepare('DELETE FROM simul_remboursements WHERE id=?').run(req.params.id);
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+125
View File
@@ -0,0 +1,125 @@
import { Router } from 'express';
import { requireAdmin } from '../middleware/auth.js';
import db from '../db/index.js';
const router = Router();
/* ── GET /api/taux-credit-impot — liste complète (auth requis, pas admin) ── */
router.get('/', (req, res) => {
const rows = db.prepare(
'SELECT * FROM taux_credit_impot ORDER BY nom_pays ASC'
).all();
res.json(rows);
});
/* ── GET /api/taux-credit-impot/:code — par code pays (pour jointure plateforme) ── */
router.get('/pays/:code', (req, res) => {
const row = db.prepare(
'SELECT * FROM taux_credit_impot WHERE UPPER(code_pays) = UPPER(?)'
).get(req.params.code);
if (!row) return res.status(404).json({ error: 'Pays introuvable' });
res.json(row);
});
/* ── POST /api/taux-credit-impot — créer (admin) ── */
router.post('/', requireAdmin, (req, res) => {
const {
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,
notice, statut_convention, date_suspension, ref_boi,
} = req.body;
if (!nom_pays?.trim()) {
return res.status(400).json({ error: 'Le nom du pays est requis.' });
}
try {
const result = 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
nom_pays.trim(),
code_pays?.trim()?.toUpperCase() || null,
div_taux != null ? Number(div_taux) : null,
div_taux_alt != null ? Number(div_taux_alt) : null,
div_taux_alt_label?.trim() || null,
div_exclusif_residence ? 1 : 0,
int_taux != null ? Number(int_taux) : null,
int_taux_alt != null ? Number(int_taux_alt) : null,
int_taux_alt_label?.trim() || null,
int_exclusif_residence ? 1 : 0,
statut_convention || 'active',
date_suspension || null,
ref_boi?.trim() || null,
notice?.trim() || null,
);
const row = db.prepare('SELECT * FROM taux_credit_impot WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(row);
} catch (e) {
if (e.message?.includes('UNIQUE')) {
return res.status(409).json({ error: 'Ce pays existe déjà.' });
}
throw e;
}
});
/* ── PUT /api/taux-credit-impot/:id — modifier (admin) ── */
router.put('/:id', requireAdmin, (req, res) => {
const {
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,
notice, statut_convention, date_suspension, ref_boi,
} = req.body;
if (!nom_pays?.trim()) {
return res.status(400).json({ error: 'Le nom du pays est requis.' });
}
const existing = db.prepare('SELECT id FROM taux_credit_impot WHERE id = ?').get(Number(req.params.id));
if (!existing) return res.status(404).json({ error: 'Introuvable' });
db.prepare(`
UPDATE taux_credit_impot SET
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 = ?,
notice = ?, statut_convention = ?, date_suspension = ?, ref_boi = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
nom_pays.trim(),
code_pays?.trim()?.toUpperCase() || null,
div_taux != null && div_taux !== '' ? Number(div_taux) : null,
div_taux_alt != null && div_taux_alt !== '' ? Number(div_taux_alt) : null,
div_taux_alt_label?.trim() || null,
div_exclusif_residence ? 1 : 0,
int_taux != null && int_taux !== '' ? Number(int_taux) : null,
int_taux_alt != null && int_taux_alt !== '' ? Number(int_taux_alt) : null,
int_taux_alt_label?.trim() || null,
int_exclusif_residence ? 1 : 0,
notice?.trim() || null,
statut_convention || 'active',
date_suspension || null,
ref_boi?.trim() || null,
Number(req.params.id),
);
const row = db.prepare('SELECT * FROM taux_credit_impot WHERE id = ?').get(Number(req.params.id));
res.json(row);
});
/* ── DELETE /api/taux-credit-impot/:id — supprimer (admin) ── */
router.delete('/:id', requireAdmin, (req, res) => {
const existing = db.prepare('SELECT id FROM taux_credit_impot WHERE id = ?').get(Number(req.params.id));
if (!existing) return res.status(404).json({ error: 'Introuvable' });
db.prepare('DELETE FROM taux_credit_impot WHERE id = ?').run(Number(req.params.id));
res.status(204).end();
});
export default router;
+421
View File
@@ -0,0 +1,421 @@
import { Router } from 'express';
import db from '../db/index.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
const router = Router();
router.use(requireInvestisseur);
const round2 = v => Math.round((v ?? 0) * 100) / 100;
router.get('/', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.query.scope === 'all';
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
const recap = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts),0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux),0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire,
COALESCE(SUM(r.net_recu),0) AS net_recu,
COUNT(*) AS nb_remboursements
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
`).get(invArg, annee);
const pertes = db.prepare(`
SELECT i.id, i.nom_projet, p.nom AS plateforme_nom,
i.montant_investi,
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) AS capital_rembourse,
(i.montant_investi -
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0)
) AS perte_capital,
i.statut, i.updated_at
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND i.statut IN ('en_retard','cloture')
AND substr(i.updated_at,1,4) = ?
`).all(invArg, annee);
const pertesTotales = pertes.reduce((s, p) => s + Math.max(0, p.perte_capital), 0);
const detail = db.prepare(`
SELECT i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
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,
COUNT(r.id) AS nb_echeances
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
GROUP BY i.id
ORDER BY interets_bruts DESC
`).all(invArg, annee);
const corrCond = scopeAll
? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'c.investisseur_id = ?';
const corrections = db.prepare(`
SELECT c.id, c.date, c.montant, c.notes,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom
FROM corrections_solde c
JOIN plateformes p ON p.id = c.plateforme_id
JOIN investisseurs inv ON inv.id = c.investisseur_id
WHERE ${corrCond}
AND substr(c.date,1,4) = ?
ORDER BY c.date DESC
`).all(invArg, annee);
const totalCorrections = corrections.reduce((s, c) => s + c.montant, 0);
const interetsNets = recap.interets_bruts - recap.prelev_sociaux - recap.prelev_forfaitaire;
const recapTotal = {
interets_bruts: round2(recap.interets_bruts),
prelev_sociaux: round2(recap.prelev_sociaux),
prelev_forfaitaire: round2(recap.prelev_forfaitaire),
interets_nets: round2(interetsNets),
interets_nets_corriges: round2(interetsNets + totalCorrections),
nb_remboursements: recap.nb_remboursements,
total_corrections: round2(totalCorrections),
};
const cases = {
case_2TR: round2(recap.interets_bruts),
case_2CK: round2(recap.prelev_forfaitaire),
case_2BH: round2(recap.interets_bruts),
case_2BH_perte_capital: round2(pertesTotales),
note: "Les cases sont indicatives. Verifiez avec votre situation fiscale reelle.",
};
res.json({ annee, recap: recapTotal, pertes, pertesTotales: round2(pertesTotales), detail, corrections, cases });
});
router.get('/export', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.query.scope === 'all';
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
const rows = db.prepare(`
SELECT i.nom_projet, p.nom AS plateforme,
r.date_remb, r.capital, r.interets_bruts,
r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu, r.statut
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond} AND substr(r.date_remb,1,4) = ?
ORDER BY r.date_remb
`).all(invArg, annee);
const corrCond2 = scopeAll
? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'c.investisseur_id = ?';
const corrRows = db.prepare(`
SELECT c.notes AS nom_projet, p.nom AS plateforme, c.date AS date_remb,
0 AS capital, 0 AS interets_bruts,
0 AS prelev_sociaux, 0 AS prelev_forfaitaire,
c.montant AS net_recu, 'correction_solde' AS statut
FROM corrections_solde c
JOIN plateformes p ON p.id = c.plateforme_id
WHERE ${corrCond2} AND substr(c.date,1,4) = ?
ORDER BY c.date
`).all(invArg, annee);
const header = ['Projet','Plateforme','Date','Capital','Interets bruts','Prelev sociaux','Impot revenu','Net recu','Statut'];
const allRows = [...rows, ...corrRows];
const csv = [
header.join(';'),
...allRows.map(r => [
esc(r.nom_projet), esc(r.plateforme), r.date_remb,
r.capital, r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
r.net_recu, r.statut,
].join(';')),
].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="2778-SD-${annee}.csv"`);
res.send('\uFEFF' + csv);
});
/* ── CERFA 2561 — synthèse par plateforme × investisseur ── */
router.get('/cerfa2561', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.query.scope === 'all';
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
const rows = db.prepare(`
SELECT
p.id AS plateforme_id,
p.nom AS plateforme_nom,
p.domiciliation,
p.fiscalite,
p.type_produit_fiscal,
inv.id AS investisseur_id,
inv.nom AS investisseur_nom,
inv.prenom AS investisseur_prenom,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
GROUP BY p.id, inv.id
ORDER BY p.nom, inv.nom
`).all(invArg, annee);
// Pertes en capital par plateforme × investisseur
const pertesRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
COALESCE(SUM(
i.montant_investi -
COALESCE((SELECT SUM(r2.capital) FROM remboursements r2 WHERE r2.investissement_id = i.id),0)
), 0) AS perte_capital
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND i.statut IN ('en_retard','cloture')
AND substr(i.updated_at,1,4) = ?
GROUP BY p.id, i.investisseur_id
`).all(invArg, annee);
const pertesMap = {};
for (const p of pertesRows) {
pertesMap[`${p.plateforme_id}_${p.investisseur_id}`] = Math.max(0, p.perte_capital);
}
const lignes = rows.map(r => {
const key = `${r.plateforme_id}_${r.investisseur_id}`;
const perteCapital = pertesMap[key] ?? 0;
const isFlatTax = r.domiciliation === 'FR' && r.fiscalite === 'flat_tax';
const use2TR = r.type_produit_fiscal === '2TR';
const interetsNets = round2(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire);
return {
annee,
plateforme_id: r.plateforme_id,
plateforme_nom: r.plateforme_nom,
domiciliation: r.domiciliation,
fiscalite: r.fiscalite,
type_produit_fiscal: r.type_produit_fiscal,
investisseur_id: r.investisseur_id,
investisseur_nom: r.investisseur_nom,
investisseur_prenom: r.investisseur_prenom,
interets_bruts: round2(r.interets_bruts),
prelev_sociaux: round2(r.prelev_sociaux),
prelev_forfaitaire: round2(r.prelev_forfaitaire),
interets_nets: interetsNets,
// Cases fiscales — d'après IFU réels :
// 2TT ou 2TR = intérêts bruts (toutes plateformes)
// 2BH = intérêts bruts si PS déjà prélevés (prelev_sociaux > 0)
// 2CK = PFNL déjà versé (flat-tax FR uniquement)
// 2TY = pertes en capital
case_2TT: !use2TR ? Math.round(r.interets_bruts) : 0,
case_2TR: use2TR ? Math.round(r.interets_bruts) : 0,
case_2BH: r.prelev_sociaux > 0 ? Math.round(r.interets_bruts) : 0,
case_2CK: isFlatTax ? Math.round(r.prelev_forfaitaire) : 0,
case_2TY: perteCapital > 0 ? Math.round(perteCapital) : 0,
};
});
// Breakdown mensuel par plateforme × investisseur
const moisRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
GROUP BY p.id, i.investisseur_id, mois
`).all(invArg, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = {
interets_bruts: round2(m.interets_bruts),
prelev_sociaux: round2(m.prelev_sociaux),
prelev_forfaitaire: round2(m.prelev_forfaitaire),
};
}
const lignesWithMois = lignes.map(l => ({
...l,
mois: moisMap[`${l.plateforme_id}_${l.investisseur_id}`] ?? {},
}));
res.json({ annee, lignes: lignesWithMois });
});
/* ── CERFA 2561 — détail des remboursements par plateforme × investisseur ── */
router.get('/cerfa2561/remboursements', (req, res) => {
const { annee, plateforme_id, investisseur_id } = req.query;
if (!annee || !plateforme_id) return res.status(400).json({ error: 'annee et plateforme_id requis' });
const scopeAll = req.query.scope === 'all';
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
const platCond = investisseur_id
? 'AND p.id = ? AND i.investisseur_id = ?'
: 'AND p.id = ?';
const platArgs = investisseur_id
? [Number(plateforme_id), Number(investisseur_id)]
: [Number(plateforme_id)];
const rows = db.prepare(`
SELECT
r.id, r.date_remb, r.capital, r.interets_bruts,
r.prelev_sociaux, r.prelev_forfaitaire, r.interets_nets, r.net_recu,
r.statut, r.notes,
i.nom_projet
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond} ${platCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
ORDER BY r.date_remb
`).all(invArg, ...platArgs, annee);
res.json(rows);
});
/* ── Années disponibles ── */
router.get('/years', (req, res) => {
const scopeAll = req.query.scope === 'all';
let invWhere, invParams;
if (scopeAll) {
invWhere = 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [req.user.id];
} else {
const invId = Number(req.header('X-Investisseur-Id'));
if (!invId) return res.status(400).json({ error: 'Missing investisseur id' });
invWhere = 'i.investisseur_id = ?';
invParams = [invId];
}
const rows = db.prepare(`
SELECT DISTINCT strftime('%Y', r.date_remb) AS annee
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${invWhere}
AND r.type = 'normal'
ORDER BY annee DESC
`).all(...invParams);
res.json(rows.map(r => r.annee));
});
/* ── 2778-SD — matrice mensuelle par plateforme étrangère ── */
router.get('/2778', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.query.scope === 'all';
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
// Plateformes étrangères × investisseur avec au moins un remboursement sur l'année
const platRows = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.domiciliation != 'FR'
ORDER BY p.nom, inv.nom
`).all(invArg, annee);
// Montants mensuels par plateforme × investisseur
const moisRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(
CASE WHEN r.interets_bruts_avant_local IS NOT NULL AND r.interets_bruts_avant_local > 0
THEN r.interets_bruts_avant_local
ELSE r.interets_bruts
END
), 0) AS montant
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.domiciliation != 'FR'
GROUP BY p.id, i.investisseur_id, mois
`).all(invArg, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = round2(m.montant);
}
const plateformes = platRows.map(p => ({
id: `${p.id}_${p.investisseur_id}`,
plateforme_id: p.id,
nom: p.nom,
investisseur_id: p.investisseur_id,
investisseur_nom: p.investisseur_nom,
investisseur_prenom: p.investisseur_prenom,
mois: moisMap[`${p.id}_${p.investisseur_id}`] ?? {},
}));
res.json({ annee, plateformes });
});
export default router;
+106
View File
@@ -0,0 +1,106 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import authRouter from './routes/auth.js';
import investisseursRouter from './routes/investisseurs.js';
import plateformesRouter from './routes/plateformes.js';
import categoriesRouter from './routes/categories.js';
import depotsRetraitsRouter from './routes/depotsRetraits.js';
import investissementsRouter from './routes/investissements.js';
import remboursementsRouter from './routes/remboursements.js';
import simulRouter from './routes/simul.js';
import dashboardRouter from './routes/dashboard.js';
import taxreportRouter from './routes/taxreport.js';
import platefomeTaxRouter from './routes/plateforme-tax.js';
import importsRouter from './routes/imports.js';
import pfuRouter from './routes/pfu.js';
import notationRouter from './routes/notation.js';
import garantiesRouter from './routes/garanties.js';
import reinvestissementsRouter from './routes/reinvestissements.js';
import correctionsRouter from './routes/corrections.js';
import comptesRouter from './routes/comptes.js';
import preferencesRouter from './routes/preferences.js';
import iconsRouter from './routes/icons.js';
import { errorHandler } from './middleware/errorHandler.js';
import { requireAuth, requireAdmin } from './middleware/auth.js';
import { startAutoStatutJob } from './jobs/autoStatut.js';
import adminRouter from './routes/admin.js';
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
import referentielRouter from './routes/referentiel.js';
import referentielPublicRouter from './routes/referentielPublic.js';
import refCategoriesRouter from './routes/ref-categories.js';
import refSecteursRouter from './routes/ref-secteurs.js';
import categoriesInvRouter from './routes/categories-inv.js';
import secteursInvRouter from './routes/secteurs-inv.js';
import associationsInvRouter from './routes/associations-inv.js';
const app = express();
const PORT = process.env.PORT || 4000;
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
app.use(cors());
app.use(express.json({ limit: '5mb' }));
app.use(morgan('dev'));
// ── Logos plateformes — servis sans authentification ─────────────────────
const logosDir = path.resolve(__dirname, '../../data/logos');
app.use('/api/logos', express.static(logosDir, { maxAge: '1d' }));
const iconsDir2 = path.resolve(__dirname, '../../data/icons');
app.use('/api/icons-files', express.static(iconsDir2, { maxAge: '1h' }));
// Basic rate-limit on auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 50,
standardHeaders: true,
legacyHeaders: false,
});
app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOString() }));
app.use('/api/auth', authLimiter, authRouter);
// All routes below require authentication
app.use('/api/investisseurs', requireAuth, investisseursRouter);
app.use('/api/plateformes', requireAuth, plateformesRouter);
app.use('/api/categories', requireAuth, categoriesRouter);
app.use('/api/depots-retraits', requireAuth, depotsRetraitsRouter);
app.use('/api/investissements', requireAuth, investissementsRouter);
app.use('/api/remboursements', requireAuth, remboursementsRouter);
app.use('/api/simul', requireAuth, simulRouter);
app.use('/api/dashboard', requireAuth, dashboardRouter);
app.use('/api/taxreport', requireAuth, taxreportRouter);
app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter);
app.use('/api/imports', requireAuth, importsRouter);
app.use('/api/pfu', requireAuth, pfuRouter);
app.use('/api/notation', requireAuth, notationRouter);
app.use('/api/garanties', requireAuth, garantiesRouter);
app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter);
app.use('/api/corrections', requireAuth, correctionsRouter);
app.use('/api/comptes', requireAuth, comptesRouter);
app.use('/api/preferences', requireAuth, preferencesRouter);
app.use('/api/icons', requireAuth, iconsRouter);
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
app.use('/api/taux-credit-impot', requireAuth, tauxCreditImpotRouter);
app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter);
app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
app.use('/api/categories-inv', requireAuth, categoriesInvRouter);
app.use('/api/secteurs-inv', requireAuth, secteursInvRouter);
app.use('/api', requireAuth, associationsInvRouter);
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Crowdlending API listening on http://localhost:${PORT}`);
startAutoStatutJob();
});
+572
View File
@@ -0,0 +1,572 @@
/**
* Shared schedule builder — used by both simul.js and investissements.js
*/
function addMonths(isoDate, months) {
const [y, m, d] = isoDate.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1 + months, d));
return dt.toISOString().slice(0, 10);
}
/**
* Retourne le dernier jour du mois de isoDate.
* Ex : '2024-01-15' → '2024-01-31', '2024-02-01' → '2024-02-29'
*/
function lastDayOfMonth(isoDate) {
const [y, m] = isoDate.split('-').map(Number);
const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant = dernier jour du mois courant
return dt.toISOString().slice(0, 10);
}
/**
* Ajoute n mois à isoDate et positionne le résultat sur le dernier jour du mois cible.
* Évite les débordements de JavaScript (ex : 31 jan + 1 mois = 29 fév, pas 3 mar).
* Ex : addMonthsEOM('2024-01-31', 1) → '2024-02-29'
*/
function addMonthsEOM(isoDate, months) {
const [y, m] = isoDate.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1 + months + 1, 0)); // jour 0 = dernier du mois cible
return dt.toISOString().slice(0, 10);
}
const round2 = n => Math.round(n * 100) / 100;
/**
* Builds a payment schedule.
*
* type:
* - in_fine : intérêts périodiques selon freq, capital à l'échéance
* - amortissable: mensualité/trimestrialité constante (modèle bancaire)
* - differe : aucun versement intermédiaire, tout en une fois à l'échéance
*
* freq:
* - mensuel : une échéance par mois (offset i-1)
* - trimestriel : une échéance tous les 3 mois
* - in_fine : forcé pour differe
*
* startDate = date_premiere_echeance (1ère échéance tombe sur cette date, offset 0)
*/
export function buildSchedule({ montant, taux, duree, type, freq, startDate, finDeMois = false }) {
const step = freq === 'trimestriel' ? 3 : 1;
const rPer = (taux / 100 / 12) * step;
const nPer = freq === 'in_fine' ? 1 : Math.round(duree / step);
const out = [];
// Calcule la date d'une échéance selon le mode fin-de-mois ou non.
const dateAt = (base, offsetMonths) =>
finDeMois ? addMonthsEOM(base, offsetMonths) : addMonths(base, offsetMonths);
if (type === 'differe') {
const interets = round2(montant * (taux / 100 / 12) * duree);
// Pour un prêt différé, date_premiere_echeance == date_cible (versement unique).
// startDate est déjà la bonne date ; en mode fin-de-mois on s'assure qu'elle
// correspond bien au dernier jour (idempotent si déjà positionné par le frontend).
out.push({
n: 1,
date: finDeMois ? lastDayOfMonth(startDate) : startDate,
capital: round2(montant),
interets,
total: round2(montant + interets),
});
} else if (type === 'in_fine') {
const interetsPer = round2(montant * rPer);
for (let i = 1; i <= nPer; i++) {
const isLast = i === nPer;
out.push({
n: i,
date: dateAt(startDate, (i - 1) * step),
capital: isLast ? round2(montant) : 0,
interets: interetsPer,
total: round2(interetsPer + (isLast ? montant : 0)),
});
}
} else {
// amortissable
const a = rPer === 0
? montant / nPer
: montant * rPer / (1 - Math.pow(1 + rPer, -nPer));
let restant = montant;
for (let i = 1; i <= nPer; i++) {
const interets = round2(restant * rPer);
const capital = round2(a - interets);
restant = round2(restant - capital);
out.push({
n: i,
date: dateAt(startDate, (i - 1) * step),
capital,
interets,
total: round2(a),
});
}
}
return out;
}
/** Nombre de mois entiers entre deux dates ISO */
function monthsDiff(isoA, isoB) {
const a = new Date(isoA);
const b = new Date(isoB);
return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
}
/**
* Recalcule les simul_remboursements FUTURS en tenant compte des remboursements
* de capital déjà enregistrés (remboursement anticipé partiel) ET des réinvestissements.
*
* - Les entrées dont date_prevue <= dernière date de remboursement capital sont laissées intactes.
* - Les entrées suivantes sont recalculées sur la base du capital restant dû.
* - Si aucun capital n'a encore été remboursé → régénération complète.
* - Les réinvestissements planifiés après la dernière date de remboursement sont pris en compte
* pour gonfler progressivement le capital des périodes futures.
*/
export function adjustSimulForActuals(db, investissementId) {
const inv = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
FROM investissements WHERE id = ?
`).get(investissementId);
if (!inv || !inv.taux_interet || !inv.duree_mois) return;
// Réinvestissements triés par date (peuvent être vides)
const reinvests = db.prepare(
'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(investissementId);
const { total_capital } = db.prepare(
'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ?'
).get(investissementId);
// Aucun capital remboursé → régénération complète (avec réinvestissements si présents)
if (total_capital <= 0) {
if (reinvests.length) {
generateSimulWithReinvestissements(db, investissementId);
} else {
generateSimul(db, inv);
}
adjustFirstPartialPeriod(db, investissementId);
return;
}
// Date du dernier remboursement comportant du capital
const { last_date } = db.prepare(
'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0'
).get(investissementId);
// Capital effectivement investi jusqu'à la date du dernier remboursement
// (initial + réinvestissements survenus avant ou à cette date)
const capitalAtLastDate = round2(
inv.montant_investi +
reinvests.filter(r => r.date_reinvestissement <= last_date).reduce((s, r) => s + r.montant, 0)
);
const remainingCapital = round2(capitalAtLastDate - total_capital);
// Entrées de simulation à recalculer (strictement après la date du dernier remb capital)
const futureEntries = db.prepare(`
SELECT * FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue > ?
ORDER BY numero_echeance
`).all(investissementId, last_date);
if (futureEntries.length === 0) return;
// Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante
if (remainingCapital <= 0) {
// Entrée simul qui couvre la période du dernier remboursement capital
const lastPaidEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu
FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue <= ?
ORDER BY date_prevue DESC
LIMIT 1
`).get(investissementId, last_date);
db.transaction(() => {
// Supprimer les échéances futures devenues caduques
const stmtDel = db.prepare('DELETE FROM simul_remboursements WHERE id=?');
for (const e of futureEntries) stmtDel.run(e.id);
// Mettre à jour l'échéance courante pour qu'elle reflète le capital soldé
if (lastPaidEntry) {
db.prepare(`
UPDATE simul_remboursements
SET capital_prevu=?, total_prevu=?
WHERE id=?
`).run(
capitalAtLastDate,
round2(capitalAtLastDate + lastPaidEntry.interets_prevus),
lastPaidEntry.id,
);
}
})();
return;
}
const step = inv.freq_interets === 'trimestriel' ? 3 : 1;
const rPer = (inv.taux_interet / 100 / 12) * step;
const nFuture = futureEntries.length;
const type = inv.type_remb || 'in_fine';
// Réinvestissements encore à venir (après la date du dernier remboursement)
const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date);
const updates = [];
if (type === 'differe') {
// Versement unique : recalcul sur capital restant + réinvestissements futurs
const entry = futureEntries[0];
const extraCapital = futureReinvests
.filter(r => r.date_reinvestissement <= entry.date_prevue)
.reduce((s, r) => s + r.montant, 0);
const totalCap = round2(remainingCapital + extraCapital);
const moisRestants = monthsDiff(last_date, entry.date_prevue);
const interets = round2(totalCap * (inv.taux_interet / 100 / 12) * moisRestants);
updates.push({ id: entry.id, capital: totalCap, interets, total: round2(totalCap + interets) });
} else if (type === 'in_fine') {
// Intérêts sur capital courant (augmente à chaque réinvestissement futur)
let capital = remainingCapital;
let reinvestIdx = 0;
futureEntries.forEach((entry, i) => {
while (reinvestIdx < futureReinvests.length &&
futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) {
capital += futureReinvests[reinvestIdx].montant;
reinvestIdx++;
}
const isLast = i === nFuture - 1;
const interets = round2(capital * rPer);
updates.push({
id: entry.id,
capital: isLast ? capital : 0,
interets,
total: round2(interets + (isLast ? capital : 0)),
});
});
} else {
// Amortissable : recalcul période par période avec capital courant
let restant = remainingCapital;
let reinvestIdx = 0;
futureEntries.forEach((entry, i) => {
while (reinvestIdx < futureReinvests.length &&
futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) {
restant += futureReinvests[reinvestIdx].montant;
reinvestIdx++;
}
const periodsLeft = nFuture - i;
const a = rPer === 0
? restant / periodsLeft
: restant * rPer / (1 - Math.pow(1 + rPer, -periodsLeft));
const interets = round2(restant * rPer);
const capital = round2(a - interets);
restant = round2(restant - capital);
updates.push({ id: entry.id, capital, interets, total: round2(a) });
});
}
db.transaction(() => {
const stmt = db.prepare(`
UPDATE simul_remboursements
SET capital_prevu = ?, interets_prevus = ?, total_prevu = ?
WHERE id = ?
`);
for (const u of updates) stmt.run(u.capital, u.interets, u.total, u.id);
})();
// Ajustement de la première période partielle (mois incomplet)
adjustFirstPartialPeriod(db, investissementId);
}
/**
* Détecte et corrige la première période partielle (mois incomplet au démarrage du prêt).
*
* Pour les prêts in_fine et amortissables, la première échéance avec intérêts correspond
* parfois à un mois incomplet (souscription en milieu de mois). Si le premier remboursement
* réel avec intérêts est inférieur au montant simulé, on met à jour la ligne simul
* correspondante avec la valeur réelle et on reporte la différence sur la dernière échéance.
*
* L'ajustement est idempotent : si les valeurs sont déjà cohérentes, rien n'est modifié.
*/
function adjustFirstPartialPeriod(db, investissementId) {
const inv = db.prepare(
'SELECT type_remb FROM investissements WHERE id = ?'
).get(investissementId);
if (!inv || inv.type_remb === 'differe') return;
// Premier remboursement enregistré avec des intérêts (exclure cashback-only)
const firstRemb = db.prepare(`
SELECT date_remb, interets_bruts
FROM remboursements
WHERE investissement_id = ? AND interets_bruts > 0
ORDER BY date_remb ASC
LIMIT 1
`).get(investissementId);
if (!firstRemb) return;
const firstRembMonth = firstRemb.date_remb.slice(0, 7); // YYYY-MM
// Entrée simul du même mois YYYY-MM
const simulEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu, total_prevu
FROM simul_remboursements
WHERE investissement_id = ? AND substr(date_prevue, 1, 7) = ?
`).get(investissementId, firstRembMonth);
if (!simulEntry) return;
const diff = round2(simulEntry.interets_prevus - firstRemb.interets_bruts);
if (diff <= 0.001) return; // Pas d'écart significatif, aucune correction nécessaire
// Dernière échéance simul (celle qui absorbera la différence)
const lastEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu, total_prevu
FROM simul_remboursements
WHERE investissement_id = ?
ORDER BY numero_echeance DESC
LIMIT 1
`).get(investissementId);
if (!lastEntry || lastEntry.id === simulEntry.id) return;
const newFirstInterets = round2(firstRemb.interets_bruts);
const newFirstTotal = round2(simulEntry.capital_prevu + newFirstInterets);
const newLastInterets = round2(lastEntry.interets_prevus + diff);
const newLastTotal = round2(lastEntry.capital_prevu + newLastInterets);
db.transaction(() => {
db.prepare(
'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?'
).run(newFirstInterets, newFirstTotal, simulEntry.id);
db.prepare(
'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?'
).run(newLastInterets, newLastTotal, lastEntry.id);
})();
}
/**
* Génère (ou régénère) le tableau d'amortissement en tenant compte des réinvestissements.
*
* Pour chaque période, le capital pris en compte est :
* montant_investi + SUM(reinvestissements dont date <= date_période)
*
* - in_fine : intérêts recalculés sur le capital cumulé à chaque période,
* capital final = capital total cumulé
* - differe : versement unique recalculé sur le capital total à l'échéance
* - amortissable : intérêts de base + quote-part du réinvestissement sur les périodes restantes
*/
export function generateSimulWithReinvestissements(db, investissementId) {
const inv = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
FROM investissements WHERE id = ?
`).get(investissementId);
if (!inv) return;
const reinvests = db.prepare(
'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(investissementId);
// Pas de réinvestissement → génération standard
if (!reinvests.length) {
generateSimul(db, inv);
return;
}
if (!inv.taux_interet || !inv.duree_mois) return;
const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription;
if (!startDate) return;
const finDeMois = !!inv.echeance_fin_de_mois;
const type = inv.type_remb || 'in_fine';
const freq = inv.freq_interets || 'mensuel';
const step = freq === 'trimestriel' ? 3 : 1;
const rPer = (inv.taux_interet / 100 / 12) * step;
// Durée effective (tient compte d'une éventuelle restructuration)
let effectiveDuree = inv.duree_mois;
if (inv.date_debut_simul && inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) {
const elapsed = monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul);
effectiveDuree = Math.max(1, inv.duree_mois - elapsed);
}
// Calendrier de base pour obtenir les dates de chaque échéance
const baseSchedule = buildSchedule({
montant: inv.montant_investi,
taux: inv.taux_interet,
duree: effectiveDuree,
type, freq, startDate, finDeMois,
});
let capital = inv.montant_investi;
let reinvestIdx = 0;
const schedule = [];
// Pour amortissable : on garde la structure du capital initial et ajoute l'impact
// du réinvestissement sur les périodes restantes (quote-part d'intérêts supplémentaires).
// Indexes des réinvestissements non encore traités dans le plan amorti.
let pendingReinvests = [];
for (let i = 0; i < baseSchedule.length; i++) {
const entry = baseSchedule[i];
// Réinvestissements dont la date tombe avant (ou sur) cette échéance
while (reinvestIdx < reinvests.length &&
reinvests[reinvestIdx].date_reinvestissement <= entry.date) {
const r = reinvests[reinvestIdx];
capital += r.montant;
// Pour amortissable : mémoriser le montant réinvesti et le nb de périodes restantes
if (type === 'amortissable') {
pendingReinvests.push({ montant: r.montant, remainingPeriods: baseSchedule.length - i });
}
reinvestIdx++;
}
const isLast = i === baseSchedule.length - 1;
let entryCapital, entryInterets, entryTotal;
if (type === 'in_fine') {
entryInterets = round2(capital * rPer);
entryCapital = isLast ? capital : 0;
entryTotal = round2(entryInterets + entryCapital);
} else if (type === 'differe') {
// Versement unique : intérêts recalculés sur le capital total depuis le début
const totalMonths = inv.duree_mois;
entryInterets = round2(capital * (inv.taux_interet / 100 / 12) * totalMonths);
entryCapital = capital;
entryTotal = round2(entryCapital + entryInterets);
} else {
// Amortissable : intérêts de base + quote-part des réinvestissements
const baseInterets = entry.interets;
const extraInterets = pendingReinvests.reduce((sum, pr) => {
return sum + round2(pr.montant * rPer);
}, 0);
entryInterets = round2(baseInterets + extraInterets);
entryCapital = entry.capital;
entryTotal = round2(entryCapital + entryInterets);
}
schedule.push({ n: entry.n, date: entry.date, capital: entryCapital, interets: entryInterets, total: entryTotal });
}
db.transaction(() => {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of schedule) {
stmt.run(investissementId, e.n, e.date, e.capital, e.interets, e.total);
}
})();
}
/**
* Génère (ou régénère) le tableau d'amortissement d'un investissement dans la DB.
* Ne fait rien si taux_interet ou duree_mois est absent.
*
* Si date_debut_simul est renseigné (restructuration de prêt), la simulation
* démarre à cette date et la durée effective est réduite du nombre de mois déjà
* écoulés depuis date_premiere_echeance, afin de ne pas allonger artificiellement le prêt.
*/
export function generateSimul(db, inv) {
const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois } = inv;
if (!taux_interet || !duree_mois) return;
// date_debut_simul remplace le point de départ quand le prêt a été restructuré
const startDate = date_debut_simul || date_premiere_echeance || date_souscription;
if (!startDate) return;
// Durée effective : si restructuration, on soustrait les mois déjà écoulés
// pour que la simulation se termine bien à la date cible contractuelle d'origine.
let effectiveDuree = duree_mois;
if (date_debut_simul && date_premiere_echeance && date_debut_simul > date_premiere_echeance) {
const elapsed = monthsDiff(date_premiere_echeance, date_debut_simul);
effectiveDuree = Math.max(1, duree_mois - elapsed);
}
const echeances = buildSchedule({
montant: montant_investi,
taux: taux_interet,
duree: effectiveDuree,
type: type_remb || 'in_fine',
freq: freq_interets || 'mensuel',
startDate,
finDeMois: !!echeance_fin_de_mois,
});
const tx = db.transaction(() => {
if (date_debut_simul) {
// ── Mode restructuration ──────────────────────────────────────────────
// Conserver uniquement les échéances déjà payées avant la date de restructuration
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
// Les échéances de la période creuse (non payées entre fin de la phase initiale
// et date_debut_simul) sont supprimées avec tout ce qui suit.
const keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(id, date_debut_simul);
if (keptEntries.length > 0) {
// Supprime tout sauf les entrées payées conservées
db.prepare(
`DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})`
).run(id, ...keptEntries.map(e => e.id));
} else {
// Rien à conserver → suppression totale
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
}
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
const elapsedMonths = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
? monthsDiff(date_premiere_echeance, date_debut_simul)
: keptEntries.length; // fallback : nombre de lignes conservées
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of echeances) {
stmt.run(id, elapsedMonths + e.n, e.date, e.capital, e.interets, e.total);
}
} else {
// ── Mode standard : régénération complète ────────────────────────────
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of echeances) {
stmt.run(id, e.n, e.date, e.capital, e.interets, e.total);
}
}
});
tx();
}
+157
View File
@@ -0,0 +1,157 @@
/**
* zip.js — Minimal pure-Node.js ZIP builder / reader.
* No external dependencies — uses only Node's built-in zlib.
*/
import zlib from 'zlib';
// ── CRC-32 ────────────────────────────────────────────────────────────────
const _crcTable = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c;
}
return t;
})();
function crc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ _crcTable[(crc ^ buf[i]) & 0xFF];
return (crc ^ 0xFFFFFFFF) >>> 0;
}
// ── DOS date/time ─────────────────────────────────────────────────────────
function dosDateTime(d = new Date()) {
const time = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
const date = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
return { time, date };
}
/**
* createZip(entries) → Buffer
* entries: [{ name: string, data: Buffer|string }]
*/
export function createZip(entries) {
const chunks = [];
const centralHeaders = [];
let offset = 0;
const { time: modTime, date: modDate } = dosDateTime();
for (let { name, data } of entries) {
if (typeof data === 'string') data = Buffer.from(data, 'utf8');
const nameBuf = Buffer.from(name, 'utf8');
const crc = crc32(data);
const uncompSize = data.length;
// Try deflate; use stored if compressed is larger
const deflated = zlib.deflateRawSync(data);
const useDeflate = deflated.length < uncompSize;
const compData = useDeflate ? deflated : data;
const method = useDeflate ? 8 : 0;
// Local file header
const lh = Buffer.alloc(30 + nameBuf.length);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4);
lh.writeUInt16LE(0, 6);
lh.writeUInt16LE(method, 8);
lh.writeUInt16LE(modTime, 10);
lh.writeUInt16LE(modDate, 12);
lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(compData.length, 18);
lh.writeUInt32LE(uncompSize, 22);
lh.writeUInt16LE(nameBuf.length, 26);
lh.writeUInt16LE(0, 28);
nameBuf.copy(lh, 30);
chunks.push(lh, compData);
centralHeaders.push({ name: nameBuf, crc, method, modTime, modDate, compSize: compData.length, uncompSize, offset });
offset += lh.length + compData.length;
}
// Central directory
const cdStart = offset;
for (const h of centralHeaders) {
const cd = Buffer.alloc(46 + h.name.length);
cd.writeUInt32LE(0x02014b50, 0);
cd.writeUInt16LE(20, 4);
cd.writeUInt16LE(20, 6);
cd.writeUInt16LE(0, 8);
cd.writeUInt16LE(h.method, 10);
cd.writeUInt16LE(h.modTime, 12);
cd.writeUInt16LE(h.modDate, 14);
cd.writeUInt32LE(h.crc, 16);
cd.writeUInt32LE(h.compSize, 20);
cd.writeUInt32LE(h.uncompSize, 24);
cd.writeUInt16LE(h.name.length, 28);
cd.writeUInt16LE(0, 30);
cd.writeUInt16LE(0, 32);
cd.writeUInt16LE(0, 34);
cd.writeUInt16LE(0, 36);
cd.writeUInt32LE(0, 38);
cd.writeUInt32LE(h.offset, 42);
h.name.copy(cd, 46);
chunks.push(cd);
offset += cd.length;
}
const cdSize = offset - cdStart;
// End of central directory
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(centralHeaders.length, 8);
eocd.writeUInt16LE(centralHeaders.length, 10);
eocd.writeUInt32LE(cdSize, 12);
eocd.writeUInt32LE(cdStart, 16);
eocd.writeUInt16LE(0, 20);
chunks.push(eocd);
return Buffer.concat(chunks);
}
/**
* readZip(buffer) → [{ name: string, data: Buffer }]
*/
export function readZip(buffer) {
// Scan backwards for EOCD signature
let eocdOffset = -1;
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65558); i--) {
if (buffer.readUInt32LE(i) === 0x06054b50) { eocdOffset = i; break; }
}
if (eocdOffset === -1) throw new Error('ZIP invalide : signature EOCD introuvable');
const entryCount = buffer.readUInt16LE(eocdOffset + 8);
const cdOffset = buffer.readUInt32LE(eocdOffset + 16);
const entries = [];
let pos = cdOffset;
for (let i = 0; i < entryCount; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte');
const method = buffer.readUInt16LE(pos + 10);
const compSize = buffer.readUInt32LE(pos + 20);
const uncompSize = buffer.readUInt32LE(pos + 24);
const nameLen = buffer.readUInt16LE(pos + 28);
const extraLen = buffer.readUInt16LE(pos + 30);
const commentLen = buffer.readUInt16LE(pos + 32);
const localOffset = buffer.readUInt32LE(pos + 42);
const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen);
// Read local file header to get actual extra field length
const localNameLen = buffer.readUInt16LE(localOffset + 26);
const localExtraLen = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
const compData = buffer.subarray(dataStart, dataStart + compSize);
const data = method === 0 ? compData : zlib.inflateRawSync(compData);
entries.push({ name, data: Buffer.from(data) });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}