diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index 5fd9445..cbc2f11 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -56,12 +56,14 @@ function TrendBadge({ current, prev, invert = false }) { +const DEBIT_TYPES = new Set(['retrait', 'souscription', 'prelevement_ir', 'prelevement_csg', 'prelevement_local']); + // 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 (−) ; +// depot/remboursement/bonus → crédit (+) ; retrait/souscription/prélèvements → 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 (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant); if (r.type === 'correction') return r.montant; return Math.abs(r.montant); } @@ -80,6 +82,8 @@ function mouvToCSV(rows) { const MOUV_TYPE_LABELS = { depot: 'Dépôt', retrait: 'Retrait', correction: 'Correction de solde', remboursement: 'Remboursement', bonus: 'Bonus / Cashback', souscription: 'Souscription', + prelevement_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG', + prelevement_local: "Prélèvement impôts à l'étranger", }; function mouvToXLS(rows, multiDetenteur) { @@ -215,20 +219,24 @@ function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvest const isRemb = row.type === 'remboursement' || row.type === 'bonus'; const isBonus = row.type === 'bonus'; const isSouscription = row.type === 'souscription'; + const isPrelevement = row.type === 'prelevement_ir' || row.type === 'prelevement_csg' || row.type === 'prelevement_local'; const isAuto = row.source === 'auto_remboursement'; const typeLabel = isCorrection ? 'Correction de solde' : isBonus ? 'Bonus / Cashback' - : row.type === 'remboursement' ? 'Remboursement (porte-monnaie)' + : row.type === 'remboursement' ? 'Remboursement (porte-monnaie, brut)' + : row.type === 'prelevement_ir' ? 'Prélèvement impôt sur le revenu' + : row.type === 'prelevement_csg' ? 'Prélèvement CSG' + : row.type === 'prelevement_local' ? "Prélèvement impôts à l'étranger" : 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'; - // Débit (−) pour retrait/souscription, crédit (+) pour dépôt/remboursement/bonus ; + // Débit (−) pour retrait/souscription/prélèvements, crédit (+) pour dépôt/remboursement/bonus ; // la correction est déjà signée en base. - const isDebitType = row.type === 'retrait' || isSouscription; + const isDebitType = row.type === 'retrait' || isSouscription || isPrelevement; const isPos = isCorrection ? row.montant >= 0 : !isDebitType; const sign = isPos ? '+' : '−'; const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`; @@ -243,9 +251,14 @@ function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvest 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' }, + isRemb && !isBonus && { label: 'Traitement fiscal', value: row._isFlatTax + ? 'Flat tax France (PFU prélevé à la source — voir prélèvements associés)' + : row._taxeLocale > 0 + ? "Étranger avec retenue fiscale locale — voir prélèvement associé" + : 'Étranger — indicatif, aucun prélèvement à la source ici' }, + isPrelevement && row.libelle && { label: 'Projet', value: row.libelle }, isSouscription && row.libelle && { label: 'Projet', value: row.libelle }, - !isCorrection && !isRemb && !isSouscription && row.libelle && { label: 'Libellé', value: row.libelle }, + !isCorrection && !isRemb && !isSouscription && !isPrelevement && 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); @@ -295,8 +308,31 @@ function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvest {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."} + ? "Remboursement (montant brut) crédité sur le porte-monnaie puis reversé aussitôt sur le compte courant (retrait automatique associé). Les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements." + : "Montant brut du remboursement versé sur le porte-monnaie de la plateforme (méthode « portefeuille ») — les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements."} + + + )} + + {isPrelevement && ( +
+ + + + + {row.type === 'prelevement_local' + ? "Retenue fiscale prélevée localement à l'étranger, avant que le remboursement n'atteigne le porte-monnaie de la plateforme." + : "Prélèvement forfaitaire unique (PFU France) retenu à la source sur ce remboursement. Modifiable depuis la page Remboursements."}
)} @@ -334,7 +370,7 @@ function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvest
- {isAuto ? null : isRemb ? ( + {isAuto ? null : (isRemb || isPrelevement) ? ( @@ -610,49 +646,91 @@ export default function DepotsRetraits() { return true; }), [normalizedCorrections, filter.plateforme_ids, filter.year, filter.month]); - // 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. + // Investissements par id (déjà scopés) — sert à retrouver fiscalite_override et à + // 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]); + + // Remboursements affichés comme mouvements du porte-monnaie de la plateforme, décomposés + // en plusieurs lignes pour rendre les prélèvements visibles : + // 1. "remboursement" (crédit, montant BRUT = capital + cashback + intérêts avant tout + // prélèvement, y compris avant retenue locale étrangère le cas échéant) + // 2. "prelevement_local" (débit = taxe_locale) si la plateforme retient une fiscalité + // locale à l'étranger — toujours réellement retenue, indépendamment du flat_tax. + // 3. "prelevement_ir" et "prelevement_csg" (débits) uniquement si le PFU est réellement + // retenu à la source ici (plateforme flat_tax France, non exonérée) — sinon les + // prélèvements ne sont qu'indicatifs (déclaratif) et ne doivent pas apparaître comme + // un mouvement réel du porte-monnaie. + // La somme de ces lignes reste égale à l'ancien montant unique (net_recu / brut selon le + // cas), donc le solde porte-monnaie n'est pas affecté — seule la présentation change. + // Les bonus (parrainage/plateforme) créditent le cashback, sans prélèvement. // 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). + // (source='auto_remboursement', même montant net) — on affiche donc ce mouvement aussi, + // 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}`, + const base = { _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, - }); + }; + + if (isBonus) { + const montant = r.cashback || 0; + if (!montant) continue; + out.push({ ...base, id: `remb_${r.id}`, type: 'bonus', montant }); + continue; + } + + const inv = invById.get(r.investissement_id); + const isFlatTax = plat?.fiscalite === 'flat_tax'; + const isExonere = inv?.fiscalite_override === 'exonere'; + // PFU réellement retenu à la source uniquement si flat_tax France non exonérée ; + // sinon prélèvements indicatifs seulement (déclaratif), pas un vrai mouvement de cash. + const pfuReellementRetenu = isFlatTax && !isExonere; + const taxeLocale = r.taxe_locale || 0; + const grossInterets = r.interets_bruts_avant_local > 0 ? r.interets_bruts_avant_local : (r.interets_bruts || 0); + const grossMontant = (r.capital || 0) + (r.cashback || 0) + grossInterets; + + if (grossMontant) { + out.push({ + ...base, + id: `remb_${r.id}`, + type: 'remboursement', + montant: grossMontant, + _isFlatTax: isFlatTax, + _isAutoSweep: r.methode_remboursement === 'compte_courant', + _taxeLocale: taxeLocale, + _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, + }); + } + + if (taxeLocale > 0) { + out.push({ ...base, id: `remb_${r.id}_local`, type: 'prelevement_local', montant: taxeLocale }); + } + if (pfuReellementRetenu && r.prelev_forfaitaire > 0) { + out.push({ ...base, id: `remb_${r.id}_ir`, type: 'prelevement_ir', montant: r.prelev_forfaitaire }); + } + if (pfuReellementRetenu && r.prelev_sociaux > 0) { + out.push({ ...base, id: `remb_${r.id}_csg`, type: 'prelevement_csg', montant: r.prelev_sociaux }); + } } return out; - }, [allRemb, plats]); + }, [allRemb, plats, invById]); // Filtrés (plateforme + année + mois, sans filtre type — appliqué au merge dans tableRows) const filteredRembMouvements = useMemo(() => normalizedRembMouvements.filter(r => { @@ -667,10 +745,6 @@ export default function DepotsRetraits() { // 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 @@ -1463,6 +1537,9 @@ export default function DepotsRetraits() { + + +
@@ -1599,6 +1676,35 @@ export default function DepotsRetraits() { ); } + if (r.type === 'prelevement_ir' || r.type === 'prelevement_csg' || r.type === 'prelevement_local') { + const isLocal = r.type === 'prelevement_local'; + const label = isLocal ? "prélèvement étranger" : r.type === 'prelevement_ir' ? 'prélèvement IR' : 'prélèvement CSG'; + const color = isLocal ? '#f97316' : 'var(--danger)'; + return ( + setSelectedRow(selectedRow?.id === r.id ? null : r)} + style={{ cursor: 'pointer' }}> + {fmtDate(r.date_operation)} + {r.plateforme_nom} + {multiDetenteur && {r.plateforme_detenteur_nom || '—'}} + + {label} + + {r._projet || '—'} + + − {fmtEUR(r.montant)} + + + + ); + } if (r.type === 'souscription') { return ( - (n == null ? '' : Number(n).toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })); +export const fmtEUR = (n) => { + if (n == null) return ''; + // Évite l'affichage "-0,00 €" causé par un zéro négatif JS ou une imprécision + // flottante résiduelle (ex. somme de dépôts/retraits qui s'annulent presque). + const rounded = Math.round(Number(n) * 100) / 100; + const v = rounded === 0 ? 0 : rounded; + return v.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' }); +}; export const fmtNum = (n, digits = 2) => (n == null ? '' : Number(n).toLocaleString('fr-FR', { minimumFractionDigits: digits, maximumFractionDigits: digits }));