diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index bfc47eb..5fd9445 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -56,24 +56,40 @@ function TrendBadge({ current, prev, invert = false }) { +// Montant signé, identique à ce qui est affiché à l'écran (+ crédit / − débit), +// pour permettre de sommer directement l'extraction et contrôler le solde. +// depot/remboursement/bonus → crédit (+) ; retrait/souscription → débit (−) ; +// correction → déjà signé en base (montant peut être positif ou négatif). +function signedMontant(r) { + if (r.type === 'retrait' || r.type === 'souscription') return -Math.abs(r.montant); + if (r.type === 'correction') return r.montant; + return Math.abs(r.montant); +} + function mouvToCSV(rows) { const BOM = ''; const sep = ';'; const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; - const headers = ['Date','Plateforme','Type','Montant (€)','Libellé','Référence','Source']; + const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Libellé','Référence','Source']; const data = rows.map(r => [ - r.date_operation, r.plateforme_nom||'', r.type, - String(r.montant).replace('.',','), r.libelle||'', r.reference||'', r.source||'', + r.date_operation, r.plateforme_nom||'', MOUV_TYPE_LABELS[r.type] || r.type, r._projet||'', + String(signedMontant(r)).replace('.',','), r.libelle||'', r.reference||'', r.source||'', ]); return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n'); } +const MOUV_TYPE_LABELS = { + depot: 'Dépôt', retrait: 'Retrait', correction: 'Correction de solde', + remboursement: 'Remboursement', bonus: 'Bonus / Cashback', souscription: 'Souscription', +}; + function mouvToXLS(rows, multiDetenteur) { const data = rows.map(r => ({ 'Date': r.date_operation, 'Plateforme': r.plateforme_nom || '', ...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}), - 'Type': r.type === 'depot' ? 'Dépôt' : 'Retrait', - 'Montant (€)': r.montant, + 'Type': MOUV_TYPE_LABELS[r.type] || r.type, + 'Projet': r._projet || '', + 'Montant (€)': signedMontant(r), })); const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); @@ -115,7 +131,8 @@ function mouvToJSON(rows) { date_operation: r.date_operation, plateforme_nom: r.plateforme_nom || '', type: r.type, - montant: r.montant, + projet: r._projet || null, + montant: signedMontant(r), libelle: r.libelle || null, reference: r.reference || null, source: r.source || null, @@ -184,7 +201,7 @@ function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) { } /* ── Panneau de détail d'un mouvement ────────────────────────── */ -function DetailPanel({ row, onEdit, onDeleteCorrection }) { +function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvestissement }) { if (!row) return (
@@ -194,16 +211,25 @@ function DetailPanel({ row, onEdit, onDeleteCorrection }) {
); - const isCorrection = row.type === 'correction'; - const isAuto = row.source === 'auto_remboursement'; + const isCorrection = row.type === 'correction'; + const isRemb = row.type === 'remboursement' || row.type === 'bonus'; + const isBonus = row.type === 'bonus'; + const isSouscription = row.type === 'souscription'; + const isAuto = row.source === 'auto_remboursement'; const typeLabel = isCorrection ? 'Correction de solde' + : isBonus ? 'Bonus / Cashback' + : row.type === 'remboursement' ? 'Remboursement (porte-monnaie)' + : isSouscription ? (row._isReinvest ? 'Réinvestissement' : 'Souscription à un investissement') : row.type === 'depot' ? 'Dépôt' : 'Retrait'; const editLabel = isCorrection ? 'Modifier la correction de solde' : row.type === 'depot' ? 'Modifier le dépôt' : 'Modifier le retrait'; - const isPos = row.montant >= 0; + // Débit (−) pour retrait/souscription, crédit (+) pour dépôt/remboursement/bonus ; + // la correction est déjà signée en base. + const isDebitType = row.type === 'retrait' || isSouscription; + const isPos = isCorrection ? row.montant >= 0 : !isDebitType; const sign = isPos ? '+' : '−'; const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`; const montantColor = undefined; @@ -213,8 +239,14 @@ function DetailPanel({ row, onEdit, onDeleteCorrection }) { { label: 'Type de mouvement', value: typeLabel }, { label: 'Montant', value: montantFmt, color: montantColor }, { label: 'Date', value: fmtDate(row.date_operation) }, - !isCorrection && row.libelle && { label: 'Libellé', value: row.libelle }, - !isCorrection && row.reference && { label: 'Référence', value: row.reference }, + isRemb && !isBonus && row.libelle && { label: 'Projet', value: row.libelle }, + isRemb && !isBonus && { label: 'Capital', value: fmtEUR(row._capital) }, + isRemb && !isBonus && row._cashback > 0 && { label: 'Cashback', value: fmtEUR(row._cashback) }, + isRemb && !isBonus && { label: 'Intérêts bruts', value: fmtEUR(row._interets_bruts) }, + isRemb && !isBonus && { label: 'Traitement fiscal', value: row._isFlatTax ? 'Flat tax France (PFU prélevé à la source)' : 'Étranger — indicatif, aucun prélèvement à la source ici' }, + isSouscription && row.libelle && { label: 'Projet', value: row.libelle }, + !isCorrection && !isRemb && !isSouscription && row.libelle && { label: 'Libellé', value: row.libelle }, + !isCorrection && !isRemb && row.reference && { label: 'Référence', value: row.reference }, row.notes && { label: 'Notes', value: row.notes }, ].filter(Boolean); @@ -244,6 +276,54 @@ function DetailPanel({ row, onEdit, onDeleteCorrection }) { )} + {isRemb && ( +
+ + + + + {isBonus + ? 'Bonus crédité directement au porte-monnaie de la plateforme.' + : row._isAutoSweep + ? "Remboursement crédité sur le porte-monnaie puis reversé aussitôt sur le compte courant (retrait automatique associé, même montant). Effet net nul sur le solde. Modifiable depuis la page Remboursements." + : "Remboursement versé sur le porte-monnaie de la plateforme (méthode « portefeuille »). Modifiable depuis la page Remboursements."} + +
+ )} + + {isSouscription && ( +
+ + + + + {row._isReinvest + ? "Réinvestissement automatique du capital d'intérêts dans un nouvel investissement — capital sorti du porte-monnaie de la plateforme." + : "Souscription à un investissement — capital sorti du porte-monnaie de la plateforme."} + +
+ )} +
{fields.map(f => (
@@ -254,7 +334,15 @@ function DetailPanel({ row, onEdit, onDeleteCorrection }) {
- {isAuto ? null : isCorrection ? ( + {isAuto ? null : isRemb ? ( + + ) : isSouscription ? ( + + ) : isCorrection ? ( @@ -394,6 +482,7 @@ export default function DepotsRetraits() { const [drPlatYear, setDrPlatYear] = useState(currentYear); const [allRemb, setAllRemb] = useState([]); const [allInv, setAllInv] = useState([]); + const [allReinvests, setAllReinvests] = useState([]); const [allCorrections, setAllCorrections] = useState([]); const [deleteConfirm, setDeleteConfirm] = useState(null); @@ -444,15 +533,19 @@ export default function DepotsRetraits() { setDashData(null); const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined; try { - const [r, p, d, remb, inv, corr] = await Promise.all([ + const [r, p, d, remb, inv, corr, reinvests] = await Promise.all([ api.get('/depots-retraits', scopeParams), api.get('/plateformes'), api.get('/dashboard', scopeParams), api.get('/remboursements', scopeParams), api.get('/investissements', scopeParams), api.get('/corrections', scopeParams), + // Toujours scope 'all' — filtré côté client via allInv (déjà scopé) pour respecter + // le même pattern que Plateformes.jsx (route /reinvestissements ne connaît que l'utilisateur). + api.get('/reinvestissements', { scope: 'all' }), ]); setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr); + setAllReinvests(Array.isArray(reinvests) ? reinvests : []); } catch (e) { console.error('DepotsRetraits load error:', e); } finally { setLoading(false); } @@ -517,14 +610,140 @@ export default function DepotsRetraits() { return true; }), [normalizedCorrections, filter.plateforme_ids, filter.year, filter.month]); - // tableRows : dépôts/retraits + corrections, triés par date desc + // Remboursements affichés comme mouvements du porte-monnaie de la plateforme — + // règle fiscale : flat_tax (France) → net_recu (PFU déjà prélevé) ; + // hors France → capital + cashback + intérêts bruts (pas de PFU prélevé à la source ici). + // Les bonus (parrainage/plateforme) créditent le cashback, indépendamment de la fiscalité du prêt. + // Note : les remboursements methode_remboursement='compte_courant' transitent bien par le + // porte-monnaie (crédit) avant d'en être aussitôt sortis par le retrait automatique + // (source='auto_remboursement', même montant) — on affiche donc les deux mouvements pour + // que l'historique soit lisible, même si l'effet net sur le solde est nul (cf. calcul KPI + // porte-monnaie ci-dessous, qui lui continue d'ignorer ces remboursements). + const normalizedRembMouvements = useMemo(() => { + const out = []; + for (const r of allRemb) { + const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme'; + const plat = plats.find(p => p.id === r.plateforme_id); + const montant = isBonus + ? (r.cashback || 0) + : (plat?.fiscalite === 'flat_tax' + ? (r.net_recu || 0) + : (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0)); + if (!montant) continue; + out.push({ + id: `remb_${r.id}`, + _rembId: r.id, + plateforme_id: r.plateforme_id, + plateforme_nom: r.plateforme_nom, + plateforme_detenteur_nom: r.plateforme_detenteur_nom, + date_operation: r.date_remb, + type: isBonus ? 'bonus' : 'remboursement', + montant, + libelle: r.nom_projet || '', + source: 'remboursement', + investisseur_id: r.resolved_investisseur_id ?? null, + _isFlatTax: !isBonus && plat?.fiscalite === 'flat_tax', + _isAutoSweep: !isBonus && r.methode_remboursement === 'compte_courant', + _capital: r.capital || 0, + _cashback: r.cashback || 0, + _interets_bruts: r.interets_bruts || 0, + _interets_nets: r.interets_nets || 0, + _net_recu: r.net_recu || 0, + }); + } + return out; + }, [allRemb, plats]); + + // Filtrés (plateforme + année + mois, sans filtre type — appliqué au merge dans tableRows) + const filteredRembMouvements = useMemo(() => normalizedRembMouvements.filter(r => { + if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false; + const [y, m] = r.date_operation.split('-'); + if (filter.year && y !== filter.year) return false; + if (filter.month && m !== filter.month.padStart(2,'0')) return false; + return true; + }), [normalizedRembMouvements, filter.plateforme_ids, filter.year, filter.month]); + + // Projet lié à un remboursement (utilisé pour retrouver le nom du projet + // sur les retraits AUTO, qui ne portent que remboursement_id) + const rembProjetById = useMemo(() => new Map(allRemb.map(r => [r.id, r.nom_projet || ''])), [allRemb]); + + // Investissements par id (déjà scopés) — sert à rattacher les réinvestissements + // (qui ne portent que investissement_id) à leur plateforme/projet/détenteur. + const invById = useMemo(() => new Map(allInv.map(i => [i.id, i])), [allInv]); + + // Souscriptions à un investissement (débit du porte-monnaie) + réinvestissements — + // les réinvestissements automatiques de capital d'intérêts sont aussi traités comme une + // souscription (débit) : cohérent avec la formule du solde porte-monnaie, qui les + // soustrait tous deux (capital_investi = montant_investi + Σ réinvestissements). + const normalizedSouscriptionMouvements = useMemo(() => { + const out = []; + for (const inv of allInv) { + if (!inv.montant_investi) continue; + out.push({ + id: `sub_${inv.id}`, + _invId: inv.id, + plateforme_id: inv.plateforme_id, + plateforme_nom: inv.plateforme_nom, + plateforme_detenteur_nom: inv.plateforme_detenteur_nom, + date_operation: inv.date_souscription, + type: 'souscription', + montant: inv.montant_investi, + libelle: inv.nom_projet || '', + source: 'souscription', + investisseur_id: inv.investisseur_id ?? null, + _isReinvest: false, + }); + } + for (const rv of allReinvests) { + const inv = invById.get(rv.investissement_id); + if (!inv || !rv.montant) continue; + out.push({ + id: `reinv_${rv.id}`, + _invId: inv.id, + plateforme_id: inv.plateforme_id, + plateforme_nom: inv.plateforme_nom, + plateforme_detenteur_nom: inv.plateforme_detenteur_nom, + date_operation: rv.date_reinvestissement, + type: 'souscription', + montant: rv.montant, + libelle: inv.nom_projet || '', + source: 'reinvestissement', + investisseur_id: inv.investisseur_id ?? null, + _isReinvest: true, + }); + } + return out; + }, [allInv, allReinvests, invById]); + + const filteredSouscriptionMouvements = useMemo(() => normalizedSouscriptionMouvements.filter(r => { + if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false; + const [y, m] = r.date_operation.split('-'); + if (filter.year && y !== filter.year) return false; + if (filter.month && m !== filter.month.padStart(2,'0')) return false; + return true; + }), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]); + + // tableRows : dépôts/retraits + corrections + remboursements/bonus + souscriptions, triés par date desc. + // _projet : nom du projet quand cela a du sens (remboursement, souscription/réinvestissement, + // et retrait AUTO lié à un remboursement via remboursement_id) — vide pour dépôt/retrait + // manuel, correction, bonus. const tableRows = useMemo(() => { - const drPart = filter.type === 'correction' ? [] : rows; - const corrPart = (filter.type === 'depot' || filter.type === 'retrait') ? [] : filteredCorrections; - return [...drPart, ...corrPart].sort((a, b) => + const showDR = !filter.type || filter.type === 'depot' || filter.type === 'retrait'; + const showCorr = !filter.type || filter.type === 'correction'; + const drPart = showDR + ? rows.map(r => ({ ...r, _projet: r.remboursement_id ? (rembProjetById.get(r.remboursement_id) || '') : '' })) + : []; + const corrPart = showCorr ? filteredCorrections.map(c => ({ ...c, _projet: '' })) : []; + const rembPart = filteredRembMouvements + .filter(r => !filter.type || r.type === filter.type) + .map(r => ({ ...r, _projet: r.type === 'bonus' ? '' : (r.libelle || '') })); + const subPart = filteredSouscriptionMouvements + .filter(r => !filter.type || r.type === filter.type) + .map(r => ({ ...r, _projet: r.libelle || '' })); + return [...drPart, ...corrPart, ...rembPart, ...subPart].sort((a, b) => b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id)) ); - }, [rows, filteredCorrections, filter.type]); + }, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById]); /* ── Pagination mouvements ── */ const { @@ -1214,10 +1433,10 @@ export default function DepotsRetraits() { )} dlBlob(mouvToCSV(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')} - onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} - onJSON={() => dlBlob(mouvToJSON(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')} + disabled={tableRows.length === 0} + onCSV={() => dlBlob(mouvToCSV(tableRows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')} + onXLS={() => dlBlob(mouvToXLS(tableRows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} + onJSON={() => dlBlob(mouvToJSON(tableRows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')} />
@@ -1307,12 +1529,12 @@ export default function DepotsRetraits() { - {multiDetenteur && } + {multiDetenteur && } {tableRows.length === 0 && ( - )} @@ -1336,6 +1558,7 @@ export default function DepotsRetraits() { color: 'var(--text-muted)', }}>correction + @@ -1347,6 +1570,71 @@ export default function DepotsRetraits() { ); } + if (r.type === 'remboursement' || r.type === 'bonus') { + const isBonus = r.type === 'bonus'; + return ( + setSelectedRow(selectedRow?.id === r.id ? null : r)} + style={{ cursor: 'pointer' }}> + + + {multiDetenteur && } + + + + + ); + } + if (r.type === 'souscription') { + return ( + setSelectedRow(selectedRow?.id === r.id ? null : r)} + style={{ cursor: 'pointer' }}> + + + {multiDetenteur && } + + + + + ); + } const isAuto = r.source === 'auto_remboursement'; return ( AUTO )} +
DatePlateformeDétenteurType de mouvementMontantDatePlateformeDétenteurType de mouvementProjetMontant
+
{loading ? 'Chargement…' : 'Aucun mouvement'}
— {isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))}
{fmtDate(r.date_operation)}{r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} + {isBonus ? 'bonus' : 'remboursement'} + {r._projet || '—'} + + {fmtEUR(r.montant)} + +
{fmtDate(r.date_operation)}{r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} + souscription + {r._isReinvest && ( + RÉINVEST. + )} + {r._projet || '—'} + − {fmtEUR(r.montant)} + +
{r._projet || '—'} {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} @@ -1402,6 +1691,8 @@ export default function DepotsRetraits() { row={selectedRow} onEdit={openEdit} onDeleteCorrection={deleteCorrection} + onViewRemb={r => navigate(`/remboursements?edit-remb=${r._rembId}`)} + onViewInvestissement={r => navigate(`/investissements/${r._invId}`)} /> )}