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;