1046 lines
48 KiB
JavaScript
1046 lines
48 KiB
JavaScript
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;
|