Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
data
|
||||
uploads
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log
|
||||
*.log
|
||||
@@ -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"]
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
Generated
+2296
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user