Files
crowdlending-app/backend/src/routes/dashboard.js
T
2026-09-19 14:37:02 +02:00

1046 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ──────────────────────── */
// ?asOf=YYYY-MM-DD (optionnel) : cumule uniquement jusqu'à cette date au lieu
// de tout l'historique. Utilisé par le frontend pour le solde "à la fin de
// l'année sélectionnée", en réutilisant exactement la même formule que le
// solde live (source unique de vérité — cf. project_taxreport_interets_plateforme
// en mémoire sur les bugs de duplication de cette formule côté frontend).
const asOf = /^\d{4}-\d{2}-\d{2}$/.test(req.query.asOf || '') ? req.query.asOf : null;
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}${asOf ? ' AND date_operation <= ?' : ''}
GROUP BY plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
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'
${asOf ? 'AND r.date_remb <= ?' : ''}
GROUP BY i.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
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')
${asOf ? 'AND date_remb <= ?' : ''}
GROUP BY bonus_plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Intérêts plateforme : comme les bonus, ils créditent directement le
// porte-monnaie de la plateforme (net_recu tient déjà compte des prélèvements).
const interetsPlateformeWalletPerPlat = db.prepare(`
SELECT bonus_plateforme_id AS plateforme_id,
COALESCE(SUM(net_recu), 0) AS interets_plateforme_wallet
FROM remboursements
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
AND type = 'interets_plateforme'
${asOf ? 'AND date_remb <= ?' : ''}
GROUP BY bonus_plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
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${asOf ? ' AND rv.date_reinvestissement <= ?' : ''}), 0)
), 0) AS capital_investi
FROM investissements i
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
${asOf ? 'AND i.date_souscription <= ?' : ''}
GROUP BY i.plateforme_id
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
// Capital investi Private Equity (investissements_pe.montant_investi) — une
// plateforme peut être partagée entre le workspace crowdlending et le
// workspace Private Equity (ex. Fundora) : le porte-monnaie réel chez le
// courtier est UNIQUE, donc le capital englouti dans un deal PE doit aussi
// réduire le porte-monnaie affiché ici, même si cette page est celle du
// workspace crowdlending (17/09/26).
const capitalInvestiPePerPlat = db.prepare(`
SELECT plateforme_id, COALESCE(SUM(montant_investi), 0) AS capital_investi
FROM investissements_pe
WHERE ${invWhere}${asOf ? ' AND date_souscription <= ?' : ''}
GROUP BY plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Frais d'opération (Étape 3, 31/08/26) : un frais réglé 'source' ou 'portefeuille'
// réduit le solde du porte-monnaie. 'source' s'ajoute à la réduction déjà appliquée via
// capitalInvestiPerPlat (qui soustrait montant_investi déjà net) — cas confirmé par Olivier
// (Fundora, 31/08/26) où la plateforme débite le montant BRUT, pas seulement le net investi.
// 'portefeuille' est un versement complémentaire distinct de montant_investi, jamais câblé
// jusqu'ici. 'compte_courant' reste exclu (aucun impact sur le porte-monnaie).
// Inclut désormais aussi les frais PE (investissement_pe_id) — jusqu'ici absents (LEFT JOIN
// ajouté le 19/09/26, bug trouvé par Claude en creusant le signalement Olivier du même jour
// sur le solde indicatif : une plateforme partagée crowdlending/PE voyait son solde_portefeuille
// légèrement surestimé dès qu'un frais PE "source"/"portefeuille" existait, ce frais n'étant
// jamais déduit nulle part). COALESCE sur investisseur_id : un frais n'a jamais les deux à la
// fois (CHECK en base), donc au plus un des deux LEFT JOIN matche réellement.
// Correctif du même jour (Olivier, après coup) : pour un deal PE, 'source' n'est PAS un débit
// supplémentaire — montant_investi (déjà déduit via capitalInvestiPePerPlat plus bas) est le
// montant BRUT transféré, frais compris. Ne compter un frais PE que s'il est 'portefeuille'
// (versement complémentaire, réellement distinct) ; côté crowdlending, 'source' reste compté
// (montant_investi y est net — cf. cas Fundora du 31/08/26, modèle différent de PE).
const fraisPerPlat = db.prepare(`
SELECT f.plateforme_id,
COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
${asOf ? 'AND f.date_operation <= ?' : ''}
GROUP BY f.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// 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}${asOf ? ' AND date <= ?' : ''}
GROUP BY plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// 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 interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of capitalInvestiPePerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
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;
// Total des corrections de solde, toutes plateformes confondues (cumul complet
// quand asOf est absent) — utilisé par le KPI "Gain net depuis le début" du Dashboard.
const corrections_total = Math.round(
correctionsPerPlat.reduce((s, r) => s + r.total_correction, 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 ────────────────────────────────────────────────── */
// Inclut les remboursements normaux (liés à un investissement) et les
// "Intérêts plateforme" (type=interets_plateforme, rattachés via
// bonus_plateforme_id/bonus_investisseur_id comme les bonus, mais ce sont
// de vrais intérêts fiscalisés — contrairement aux bonus qui restent exclus).
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
LEFT JOIN investissements i ON i.id = r.investissement_id
WHERE (
(r.investissement_id IS NOT NULL AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')})
OR (r.investissement_id IS NULL AND r.type = 'interets_plateforme' AND ${invWhere.replace('investisseur_id', 'r.bonus_investisseur_id')})
)
`).get(...invParams, ...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, corrections_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);
// Capital investi Private Equity sur cette même plateforme (cf. GET / —
// capitalInvestiPePerPlat) : même porte-monnaie physique, même correction.
const capitalPe = db.prepare(`
SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi
FROM investissements_pe
WHERE plateforme_id = ? AND ${invWhere} AND date_souscription <= ?
`).get(platId, ...invParams, date);
/* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant, même
// exclusion des frais PE 'source' (déjà inclus dans montant_investi, cf. commentaire ci-dessus).
const frais = db.prepare(`
SELECT COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE f.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
AND f.date_operation <= ?
`).get(platId, ...invParams, date);
/* ── 6. 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
- capitalPe.capital_investi
- frais.total_frais
+ 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);
/* ── Intérêts plateforme (hors investissement) par plateforme et par mois ── */
const interetsPlateformeByPlat = 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.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
AND r.type = 'interets_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,
(SELECT ip.date_effet FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_date_effet,
(SELECT ip.montant_perte FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_montant
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);
// 'prolongation' compte normalement comme capital encours (comme en_retard/procedure
// dont il découle après renégociation) mais n'entre pas dans en_defaut : il est sorti
// temporairement du scope "à risque" tant que la nouvelle échéance n'est pas dépassée.
const actif = ['en_cours', 'en_retard', 'procedure', 'prolongation'].includes(inv.statut);
if (actif) {
capital += capitalRestant;
if (['en_retard', 'procedure'].includes(inv.statut)) enDefaut += capitalRestant;
} else if (inv.statut === 'perte_definitive' && inv.perte_date_effet) {
// Avant la déclaration de perte (fidélité historique) : capital compté normalement,
// comme si le dossier était encore en_retard/procedure ce mois-là. À partir du mois
// de déclaration : capital réduit du montant acté en perte (perte partielle possible).
if (lastDay < inv.perte_date_effet) {
capital += capitalRestant;
} else {
capital += Math.max(0, capitalRestant - (inv.perte_montant || 0));
}
} 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 };
}
}
// Ajouter les intérêts plateforme (hors investissement) aux remboursements par mois
for (const ip of interetsPlateformeByPlat) {
if (!rembMap[ip.plateforme_id]) rembMap[ip.plateforme_id] = { id: ip.plateforme_id, nom: ip.plateforme_nom, detenteur_nom: detenteurMap[ip.plateforme_id] ?? null, rembourses: {}, projections: {} };
const entry = rembMap[ip.plateforme_id].rembourses[ip.mois];
if (entry) {
entry.interets_bruts = (entry.interets_bruts ?? 0) + ip.interets_bruts;
entry.interets_nets = (entry.interets_nets ?? 0) + ip.interets_nets;
} else {
rembMap[ip.plateforme_id].rembourses[ip.mois] = { interets_bruts: ip.interets_bruts, interets_nets: ip.interets_nets, cashback: 0, 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, plateforme_ids, 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')}`;
/* Support filtre simple (platId) OU multiple (platIds, vue consolidée) */
const platIds = plateforme_ids
? plateforme_ids.split(',').map(Number).filter(Boolean)
: plateforme_id ? [Number(plateforme_id)].filter(Boolean) : [];
const platFilter = platIds.length
? `AND i.plateforme_id IN (${platIds.map(() => '?').join(',')})`
: '';
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 = [...invParams, ...platIds, moisStr];
const projetesParams = [...invParams, ...platIds, 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.frais_ttc, 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,
-- Étape 5 bis, 01/09/26 : le frais n'est pas toujours réglé SUR ce remboursement (cf.
-- remboursements.js, computeChamps/syncFraisDistribution) — pilote l'icône/popup de détail.
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement
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;