From 7ee011de09504b92d71521bdc9181c5d7276ce7a Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 16 Aug 2026 18:48:23 +0200 Subject: [PATCH] Correctif Calcul porte-monnaie --- backend/src/routes/dashboard.js | 29 ++++-- frontend/src/pages/DepotsRetraits.jsx | 126 +++++++++----------------- frontend/src/pages/Plateformes.jsx | 75 +++++++-------- 3 files changed, 96 insertions(+), 134 deletions(-) diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index 349966a..789ab1b 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -54,15 +54,22 @@ router.get('/', (req, res) => { `).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} + WHERE ${invWhere}${asOf ? ' AND date_operation <= ?' : ''} GROUP BY plateforme_id - `).all(...invParams); + `).all(...invParams, ...(asOf ? [asOf] : [])); const rembWalletPerPlat = db.prepare(` SELECT i.plateforme_id, @@ -80,8 +87,9 @@ router.get('/', (req, res) => { 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); + `).all(...invParams, ...(asOf ? [asOf] : [])); const bonusWalletPerPlat = db.prepare(` SELECT bonus_plateforme_id AS plateforme_id, @@ -89,8 +97,9 @@ router.get('/', (req, res) => { 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); + `).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). @@ -100,28 +109,30 @@ router.get('/', (req, res) => { 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); + `).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), 0) + + 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(...invParams); + `).all(...(asOf ? [asOf] : []), ...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} + WHERE ${invWhere}${asOf ? ' AND date <= ?' : ''} GROUP BY plateforme_id - `).all(...invParams); + `).all(...invParams, ...(asOf ? [asOf] : [])); // Fusion en une map plateforme_id → solde_portefeuille const walletMap = {}; diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index 460ac5f..3ab9980 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -101,6 +101,11 @@ export default function DepotsRetraits() { const [allRows, setAllRows] = useState([]); const [plats, setPlats] = useState([]); const [dashData, setDashData] = useState(null); + // Snapshots du solde porte-monnaie "à la fin de l'année N" (GET /dashboard?asOf=), + // clé = 'YYYY-12-31'. Remplace un ancien recalcul client-side qui avait divergé + // de la formule backend (bonus/intérêts plateforme/corrections manquants) — + // cf. mémoire project_taxreport_interets_plateforme. + const [dashDataAsOf, setDashDataAsOf] = useState({}); // Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements const currentYear = String(new Date().getFullYear()); const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' }); @@ -186,6 +191,22 @@ export default function DepotsRetraits() { useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]); + // Snapshots "fin d'année" du solde porte-monnaie, calculés côté backend + // (GET /dashboard?asOf=YYYY-12-31) : année sélectionnée (si filtre actif) + + // année précédente (toujours nécessaire pour le TrendBadge). + useEffect(() => { + if (!activeId && activeView !== 'all') return; + const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined; + const prevYear = String(Number(drPlatYear || new Date().getFullYear()) - 1); + const dates = new Set([`${prevYear}-12-31`]); + if (drPlatYear) dates.add(`${drPlatYear}-12-31`); + Promise.all([...dates].map(asOf => + api.get('/dashboard', { ...scopeParams, asOf }).then(d => [asOf, d]) + )).then(entries => { + setDashDataAsOf(prev => ({ ...prev, ...Object.fromEntries(entries) })); + }).catch(e => console.error('dashDataAsOf load error:', e)); + }, [drPlatYear, activeView, activeId]); // eslint-disable-line + // Ferme tous les menus contextuels au scroll useEffect(() => { const closeAll = () => { @@ -459,52 +480,20 @@ export default function DepotsRetraits() { else map[key].retraits += r.montant; } - // Solde porte-monnaie = cumul jusqu'au 31/12 de l'année sélectionnée - // = dépôts/retraits + remboursements crédités au portefeuille - // Règle fiscale : flat_tax → net_recu (PFU prélevé à la source) ; - // hors France → capital + cashback + interets_bruts (pas de PFU à la source) - const yearEnd = `${drPlatYear}-12-31`; - const cumul = {}; - - // 1. Dépôts et retraits manuels (hors auto-retraits — générés par remboursements - // sur compte courant, ils ne représentent pas un mouvement du porte-monnaie) - for (const r of allRows) { - if (r.date_operation > yearEnd) continue; - if (r.source === 'auto_remboursement') continue; - const key = String(r.plateforme_id); - cumul[key] = (cumul[key] ?? 0) + (r.type === 'depot' ? r.montant : -r.montant); - } - - // 2. Remboursements crédités au portefeuille - // Les remboursements vers compte_courant ne crédient pas le porte-monnaie. - // Pour flat_tax : net_recu (PFU déjà prélevé à la source). - // Pour hors France : capital + cashback + interets_bruts (pas de PFU à la source ; - // interets_bruts est déjà net de la retenue locale si applicable). - for (const r of allRemb) { - if (!r.date_remb || r.date_remb > yearEnd) continue; - if (r.methode_remboursement !== 'portefeuille') continue; - const key = String(r.plateforme_id); - const plat = plats.find(p => p.id === r.plateforme_id); - const credit = plat?.fiscalite === 'flat_tax' - ? (r.net_recu ?? 0) - : (r.capital ?? 0) + (r.cashback ?? 0) + (r.interets_bruts ?? 0); - cumul[key] = (cumul[key] ?? 0) + credit; - } - - // 3. Déduire le capital investi (capital_total = montant_investi + réinvestissements) - // Le capital revient progressivement via net_recu (étape 2) au fil des remboursements - for (const inv of allInv) { - if (!inv.date_souscription || inv.date_souscription > yearEnd) continue; - const key = String(inv.plateforme_id); - cumul[key] = (cumul[key] ?? 0) - (inv.capital_total ?? inv.montant_investi); + // Solde porte-monnaie = cumul jusqu'au 31/12 de l'année sélectionnée, calculé + // côté backend (GET /dashboard?asOf=) pour rester la même formule que le solde + // "live" — bonus, intérêts plateforme et corrections de solde inclus. + const soldeMap = {}; + for (const p of dashDataAsOf[`${drPlatYear}-12-31`]?.cashByPlatform ?? []) { + soldeMap[String(p.plateforme_id)] = p.solde_portefeuille; } return Object.values(map).map(p => { p.solde_net = p.depots - p.retraits; - p.solde_portefeuille = cumul[String(p.plateforme_id)] ?? 0; + p.solde_portefeuille = soldeMap[String(p.plateforme_id)] ?? 0; return p; }).sort((a, b) => b.solde_net - a.solde_net); - }, [allRows, allRemb, allInv, dashData, drPlatYear]); + }, [allRows, dashDataAsOf, drPlatYear]); const drPlatTotals = useMemo(() => drPlatData.reduce((acc, p) => { @@ -525,7 +514,18 @@ export default function DepotsRetraits() { // Solde porte-monnaie pour le KPI : // - sans filtre année → live depuis le backend (inclut déjà les remboursements au portefeuille) - // - avec filtre année → cumul jusqu'au 31/12 de l'année (dépôts/retraits + remboursements au portefeuille) + // - avec filtre année → snapshot backend "fin d'année" (GET /dashboard?asOf=), même + // formule que le solde live (dashDataAsOf, cf. effet plus haut) + const soldeFromSnapshot = (snap) => { + if (!snap) return null; + if (filter.plateforme_ids.length > 0) { + return (snap.cashByPlatform ?? []) + .filter(c => filter.plateforme_ids.includes(String(c.plateforme_id))) + .reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0); + } + return snap.solde_portefeuille_total ?? null; + }; + const kpiSoldePortefeuille = (() => { if (!drPlatYear) { if (filter.plateforme_ids.length === 1 && dashData) { @@ -534,26 +534,7 @@ export default function DepotsRetraits() { } return dashData?.solde_portefeuille_total ?? null; } - const yearEnd = `${drPlatYear}-12-31`; - let balance = 0; - for (const r of allRows) { - if (r.date_operation > yearEnd) continue; - if (r.source === 'auto_remboursement') continue; - if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; - balance += r.type === 'depot' ? r.montant : -r.montant; - } - for (const r of allRemb) { - if (!r.date_remb || r.date_remb > yearEnd) continue; - if (r.methode_remboursement !== 'portefeuille') continue; - if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; - balance += r.net_recu ?? 0; - } - for (const inv of allInv) { - if (!inv.date_souscription || inv.date_souscription > yearEnd) continue; - if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(inv.plateforme_id))) continue; - balance -= inv.capital_total ?? inv.montant_investi; - } - return balance; + return soldeFromSnapshot(dashDataAsOf[`${drPlatYear}-12-31`]); })(); // Totaux KPI et solde porte-monnaie pour l'année N-1 (TrendBadge) @@ -566,28 +547,7 @@ export default function DepotsRetraits() { }, { depots: 0, retraits: 0 }); })(); - const prevKpiSoldePortefeuille = (() => { - const yearEnd = `${prevYear}-12-31`; - let balance = 0; - for (const r of allRows) { - if (r.date_operation > yearEnd) continue; - if (r.source === 'auto_remboursement') continue; - if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; - balance += r.type === 'depot' ? r.montant : -r.montant; - } - for (const r of allRemb) { - if (!r.date_remb || r.date_remb > yearEnd) continue; - if (r.methode_remboursement !== 'portefeuille') continue; - if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; - balance += r.net_recu ?? 0; - } - for (const inv of allInv) { - if (!inv.date_souscription || inv.date_souscription > yearEnd) continue; - if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(inv.plateforme_id))) continue; - balance -= inv.capital_total ?? inv.montant_investi; - } - return balance; - })(); + const prevKpiSoldePortefeuille = soldeFromSnapshot(dashDataAsOf[`${prevYear}-12-31`]); /* ── Enrichissement du KPI "Diff. Dépôts vs Retraits" avec l'objectif de l'année ── Toujours calculé sur le portefeuille entier (ignore le filtre plateforme), diff --git a/frontend/src/pages/Plateformes.jsx b/frontend/src/pages/Plateformes.jsx index 5945790..df7c480 100644 --- a/frontend/src/pages/Plateformes.jsx +++ b/frontend/src/pages/Plateformes.jsx @@ -299,6 +299,11 @@ export default function Plateformes() { const [allDepots, setAllDepots] = useState([]); const [allCorrections, setAllCorrections] = useState([]); const [loading, setLoading] = useState(false); + // Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31' + // (GET /dashboard[?asOf=]) — remplace le recalcul client historique de + // computeSoldePortefeuille, cf. dashDataAsOf dans DepotsRetraits.jsx et mémoire + // project_taxreport_interets_plateforme (source unique de vérité). + const [dashDataAsOf, setDashDataAsOf] = useState({}); /* ── État sélecteurs ── */ /* selectedPlatName et activeTab sont dans l'URL pour survivre au retour arrière */ @@ -379,6 +384,22 @@ export default function Plateformes() { useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]); + // Snapshots backend du solde porte-monnaie : 'live' (aucune coupure) + l'année + // sélectionnée (si filtre actif) + l'année précédente (toujours nécessaire pour + // le TrendBadge). Même endpoint que DepotsRetraits.jsx (GET /dashboard[?asOf=]). + useEffect(() => { + if (!activeId && activeView !== 'all') return; + const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined; + const prevYear = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1; + const keys = new Set(['live', `${prevYear}-12-31`]); + if (selectedYear) keys.add(`${selectedYear}-12-31`); + Promise.all([...keys].map(k => + api.get('/dashboard', { ...scopeParams, ...(k === 'live' ? {} : { asOf: k }) }).then(d => [k, d]) + )).then(entries => { + setDashDataAsOf(prev => ({ ...prev, ...Object.fromEntries(entries) })); + }).catch(e => console.error('dashDataAsOf load error (Plateformes):', e)); + }, [selectedYear, activeView, activeId]); // eslint-disable-line + /* ── Section "Mois en cours" (DrillCellPanel) — saisie/édition de remboursements ── */ const [drillRefreshKey, setDrillRefreshKey] = useState(0); const [rembModalOpen, setRembModalOpen] = useState(false); @@ -827,53 +848,23 @@ export default function Plateformes() { const netMode = displayMode === 'net'; const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1; - /* ── Solde porte-monnaie (cf. formule dashboard.js) ────────────── - dépôts − retraits manuels + remboursements crédités au portefeuille - + bonus − capital investi + corrections de solde, cumulés sur la - sélection plateforme(s)/détenteur, jusqu'à une date de coupure. */ - const computeSoldePortefeuille = (cutoff) => { - let balance = 0; - for (const d of allDepots) { - if (!filteredPlatIds.has(d.plateforme_id)) continue; - if (cutoff && d.date_operation > cutoff) continue; - if (d.type === 'depot') balance += d.montant || 0; - else if (d.source !== 'auto_remboursement') balance -= d.montant || 0; - } - for (const r of allRembs) { - if (!filteredPlatIds.has(r.plateforme_id)) continue; - if (r.methode_remboursement !== 'portefeuille') continue; - if (!r.date_remb || (cutoff && r.date_remb > cutoff)) continue; - if (r.type === 'normal') { - const plat = plats.find(p => p.id === r.plateforme_id); - balance += plat?.fiscalite === 'flat_tax' - ? (r.net_recu || 0) - : (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0); - } else if (r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme') { - balance += r.cashback || 0; - } else if (r.type === 'interets_plateforme') { - balance += r.net_recu || 0; - } - } - for (const inv of allRows) { - if (!filteredPlatIds.has(inv.plateforme_id)) continue; - if (!inv.date_souscription || (cutoff && inv.date_souscription > cutoff)) continue; - balance -= inv.capital_total ?? inv.montant_investi ?? 0; - } - for (const c of allCorrections) { - if (!filteredPlatIds.has(c.plateforme_id)) continue; - if (cutoff && c.date > cutoff) continue; - balance += c.montant || 0; - } - return Math.round(balance * 100) / 100; + /* ── Solde porte-monnaie (source unique : GET /dashboard[?asOf=], cf. dashboard.js) ── + Snapshot backend filtré sur la sélection plateforme(s)/détenteur (filteredPlatIds). */ + const soldeFromSnapshot = (snap) => { + if (!snap) return null; + const total = (snap.cashByPlatform ?? []) + .filter(c => filteredPlatIds.has(c.plateforme_id)) + .reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0); + return Math.round(total * 100) / 100; }; const soldePortefeuille = useMemo( - () => computeSoldePortefeuille(selectedYear ? `${selectedYear}-12-31` : null), - [allDepots, allRembs, allRows, allCorrections, plats, filteredPlatIds, selectedYear] + () => soldeFromSnapshot(dashDataAsOf[selectedYear ? `${selectedYear}-12-31` : 'live']), + [dashDataAsOf, filteredPlatIds, selectedYear] ); const prevSoldePortefeuille = useMemo( - () => computeSoldePortefeuille(`${prevYearLabel}-12-31`), - [allDepots, allRembs, allRows, allCorrections, plats, filteredPlatIds, prevYearLabel] + () => soldeFromSnapshot(dashDataAsOf[`${prevYearLabel}-12-31`]), + [dashDataAsOf, filteredPlatIds, prevYearLabel] ); /* ══════════════════════════════════════════════════════════════════════