import { useEffect, useRef, useState } from 'react'; import * as XLSX from 'xlsx'; import { fmtEUR, fmtDate, today } from '../utils/format.js'; import { useAsyncAction } from '../hooks/useAsyncAction.js'; /* ── Éléments partagés entre l'onglet "Mouvements" de DepotsRetraits.jsx et l'onglet "Mouvements Porte-monnaie" de Plateformes.jsx. Toute évolution ici s'applique aux deux pages — c'est le but : éviter la dérive entre les deux implémentations. ── */ /* ── Helpers export ──────────────────────────────────────────── */ /** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */ export function timestampSuffix() { const d = new Date(); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } export function dlBlob(content, filename, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } export 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/prélèvements → débit (−) ; // correction → déjà signé en base (montant peut être positif ou négatif). export function signedMontant(r) { // Frais d'opération : impact réel sur le solde uniquement si réglé 'source' (débit du // montant brut, cf. cas Fundora confirmé par Olivier le 31/08/26 — [[project_frais_operation]]) // ou 'portefeuille' (versement complémentaire) ; 'compte_courant' n'a jamais d'effet sur le // porte-monnaie (sortie d'argent ailleurs) — on l'affiche quand même dans la liste, à titre // de traçabilité, avec un badge "hors porte-monnaie", mais sans effet sur le solde indicatif. // 'remboursement' (Étape 4, 31/08/26) : frais de distribution synchronisé depuis le champ // "Frais" d'un remboursement — DOIT être un débit réel ici (comme un prélèvement), pas 0 : // la ligne "remboursement" du tableau crédite le coupon BRUT (interets_bruts, cf. // normalizedRembMouvements), et les prélèvements sont déjà des lignes de débit séparées pour // retomber sur interets_nets — le frais doit suivre exactement la même convention, sinon la // chaîne ne retombe jamais à 0 après un retrait AUTO (confirmé par Olivier le 01/09/26 : le // reliquat observé était exactement égal au montant du frais). if (r.type === 'frais') return r.mode_reglement === 'compte_courant' ? 0 : -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); } export const MOUV_TYPE_LABELS = { depot: 'Dépôt', retrait: 'Retrait', correction: 'Correction de solde', remboursement: 'Remboursement', bonus: 'Bonus', cashback: 'Cashback', souscription: 'Souscription', prelevement_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG', prelevement_local: "Prélèvement impôts à l'étranger", interets_plateforme: 'Intérêts plateforme', frais: "Frais d'opération", }; // Labels de mode de règlement / origine d'un frais d'opération — dupliqués depuis // InvestissementDetail.jsx (bloc "Frais d'opération") pour l'affichage dans les // Mouvements ; garder synchronisés en cas d'évolution des libellés. export const FRAIS_MODE_LABELS = { source: 'Déduit du versement lors de la souscription', portefeuille: 'Porte-monnaie', compte_courant: 'Débité du compte courant', remboursement: 'Déduit du remboursement' }; export const FRAIS_ORIGINE_LABELS = { souscription: 'Souscription', remboursement: 'Remboursement', manuel: 'Manuel' }; // Catégorie de frais (31/08/26, demande Olivier) — classement visible côté utilisateur, // distinct de FRAIS_ORIGINE_LABELS (détail technique de création). Dupliqué depuis // InvestissementDetail.jsx, à garder synchronisé. export const FRAIS_CATEGORIE_LABELS = { souscription: 'Souscription', gestion: 'Gestion', distribution: 'Distribution', autre: 'Autre' }; /* ── Tri des mouvements pour une même date ─────────────────────────── 1) Toutes les lignes d'une même plateforme, pour une même date, sont regroupées ensemble (ordre alphabétique des plateformes entre elles — décidé avec Olivier le 2026-08-21, cf. incident du 20/08 où Indemo et La première brique s'entrelaçaient). 2) À l'intérieur d'un bloc plateforme+date, TOUT est ordonné du plus récent au plus ancien, à tous les niveaux — familles, chaînes distinctes, ET position au sein d'une même chaîne (cohérent avec le tri global le plus récent d'abord) : 1. Correction de solde 2. Retrait (manuel) 3. Souscription / Réinvestissement — argent qui sort pour investir └─ Cashback "prime à la souscription" du même jour sur le même investissement (affiché AVANT sa souscription : chronologiquement postérieur), si ce remboursement n'a par ailleurs aucune ligne crédit propre (cf. ci-dessous) 4. Dépôt (manuel) — alimente le compte 5. Remboursement / Intérêts plateforme / Bonus — argent qui entre └─ Retrait AUTO (sweep compte_courant) qui reverse ce remboursement └─ Prélèvement CSG └─ Prélèvement IR └─ Prélèvement impôts à l'étranger └─ Cashback (si rattaché à ce remboursement) └─ (le remboursement lui-même, ligne la plus ancienne de sa chaîne, apparaît donc en dernier — ses prélèvements/son retrait AUTO sont chronologiquement postérieurs) Plusieurs chaînes de même famille le même jour (ex. deux souscriptions) sont ordonnées par id décroissant (la plus récemment créée en premier). L'ordre interne d'une chaîne (constante MOUV_REMB_CHAIN_POS) reste défini "parent=0, enfants=1,2,3…" mais est PARCOURU EN DÉCROISSANT par le comparateur — inverser MOUV_REMB_CHAIN_POS lui-même serait une erreur, seul le sens du tri doit changer. Correction du 2026-08-21 (2e retour d'Olivier) : la 1ère version de cette règle laissait le sous-ordre d'une chaîne "parent puis enfants" non inversé, ce qui affichait "remboursement, puis ses prélèvements" — incohérent avec le reste de la journée en "plus récent d'abord". 3) DÉPÔT ENTRE SOUSCRIPTION ET REMBOURSEMENT (et non plus toujours le plus ancien) : cas typique signalé par Olivier le 2026-08-21 sur BienPrêter/17-08 — un remboursement (Tradimex) déclenche un dépôt pour "relancer des investissements", qui finance ensuite 2 nouvelles souscriptions le même jour. Chronologiquement : remboursement → dépôt → souscriptions. Il n'existe AUCUN lien en base entre un dépôt manuel et le remboursement/les souscriptions qu'il finance (contrairement au retrait AUTO ou au cashback de souscription, qui ont un `remboursement_id`/ `investissement_id` explicite) — impossible donc de rattacher précisément CE dépôt à CE remboursement. Le compromis retenu est une RÈGLE GÉNÉRALE (pas un chaînage par ligne) : un dépôt manuel est toujours supposé plus récent qu'un remboursement de la même plateforme/journée (donc affiché au-dessus de sa chaîne), mais plus ancien qu'une souscription de la même plateforme/journée (donc affiché en dessous). C'est une approximation assumée : si un dépôt sans lien avec un remboursement du jour se trouve être réellement antérieur à ce remboursement, l'affichage sera dans le mauvais sens — aucune donnée ne permet de trancher. Règle validée avec Olivier (retour direct sur capture, pas d'AskUserQuestion cette fois — cas jugé suffisamment clair). ── */ const MOUV_FAMILY_RANK = { remboursement: 1, interets_plateforme: 1, bonus: 1, cashback: 1, prelevement_local: 1, prelevement_ir: 1, prelevement_csg: 1, depot: 2, souscription: 3, frais: 3, retrait: 4, correction: 5, }; // Position au sein d'une chaîne "remboursement" (lignes partageant le même _rembId) const MOUV_REMB_CHAIN_POS = { remboursement: 0, interets_plateforme: 0, bonus: 0, // frais (Étape 4, 31/08/26) : déduit du coupon brut AVANT calcul des prélèvements (base // imposable = interets_bruts - frais_ttc, cf. computeChamps) — doit donc apparaître entre le // remboursement et les prélèvements dans la chaîne, pas au-dessus de tout (demande Olivier, // 01/09/26, avec l'avis d'opéré Kyoseil-AM/Fundora à l'appui). frais: 1, cashback: 2, prelevement_local: 3, prelevement_ir: 4, prelevement_csg: 5, }; export function sortMouvements(list) { // _rembId des remboursements qui ont une vraie ligne crédit (remboursement / intérêts // plateforme) — sert à NE PAS rattacher leur cashback à une souscription du même jour : // un cashback qui accompagne un vrai remboursement reste dans la chaîne de ce remboursement. const rembHasCreditLine = new Set( list.filter(r => r.type === 'remboursement' || r.type === 'interets_plateforme').map(r => r._rembId) ); // Souscriptions/réinvestissements par (investissement, date) — pour rattacher un cashback // "prime à la souscription" (remboursement sans ligne crédit propre, même investissement, // même date) à la souscription qu'il accompagne. const subByInvAndDate = new Map( list.filter(r => r.type === 'souscription').map(r => [`${r._invId}|${r.date_operation}`, r]) ); const enriched = list.map(row => { // Retrait AUTO (sweep compte_courant) : reste collé juste après le remboursement qui le déclenche. if (row.type === 'retrait' && row.source === 'auto_remboursement' && row.remboursement_id != null) { return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row.remboursement_id, pos: 6 }; } // Cashback "prime à la souscription" : pas de ligne crédit propre + souscription du même // jour sur le même investissement → rattaché à cette souscription plutôt qu'isolé. if (row.type === 'cashback' && !rembHasCreditLine.has(row._rembId) && row._invId != null) { const sub = subByInvAndDate.get(`${row._invId}|${row.date_operation}`); if (sub) return { row, family: MOUV_FAMILY_RANK.souscription, anchor: sub._invId, pos: 1 }; } // Frais lié à un remboursement (mode_reglement='remboursement', Étape 4, 31/08/26) : // rejoint la chaîne du remboursement lui-même (même family que prélèvements/retrait AUTO), // positionné juste après le remboursement et avant les prélèvements — cf. // MOUV_REMB_CHAIN_POS.frais ci-dessus. Doit être vérifié avant le bloc générique // `row._rembId != null` plus bas, qui sinon l'attraperait avec la family 'frais' (3), // qui n'a rien à voir avec la chaîne du remboursement. if (row.type === 'frais' && row._rembId != null) { return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row._rembId, pos: MOUV_REMB_CHAIN_POS.frais }; } // Lignes d'un remboursement (parent + cashback resté attaché + prélèvements) if (row._rembId != null) { return { row, family: MOUV_FAMILY_RANK[row.type] ?? MOUV_FAMILY_RANK.remboursement, anchor: row._rembId, pos: MOUV_REMB_CHAIN_POS[row.type] ?? 0, }; } // Frais d'opération : rattaché à l'investissement concerné (même anchor que sa // souscription éventuelle) — pos élevé pour apparaître au-dessus d'elle le même jour, // cohérent avec le principe "plus récent d'abord" (le frais est perçu comme consécutif // à la souscription). Ne rejoint jamais la chaîne d'un remboursement (_rembId) même si // origine='remboursement' : pas encore de lien fin construit ici (cf. [[project_frais_operation]]). if (row.type === 'frais') { return { row, family: MOUV_FAMILY_RANK.frais, anchor: row._invId, pos: 2 }; } // Souscription / réinvestissement (parent de sa propre chaîne) if (row.type === 'souscription') { return { row, family: MOUV_FAMILY_RANK.souscription, anchor: row._invId, pos: 0 }; } // Correction de solde if (row.type === 'correction') { return { row, family: MOUV_FAMILY_RANK.correction, anchor: row._correctionId, pos: 0 }; } // Dépôt / retrait manuel (hors sweep AUTO déjà traité plus haut) return { row, family: MOUV_FAMILY_RANK[row.type] ?? MOUV_FAMILY_RANK.retrait, anchor: row.id, pos: 0 }; }); return enriched .sort((a, b) => b.row.date_operation.localeCompare(a.row.date_operation) || // date : plus récent d'abord (a.row.plateforme_nom || '').localeCompare(b.row.plateforme_nom || '', 'fr') || // plateforme : ordre alphabétique b.family - a.family || // famille : plus récent de la journée d'abord (Number(b.anchor) - Number(a.anchor)) || // chaînes distinctes : id décroissant (plus récente d'abord) b.pos - a.pos || // au sein d'une chaîne : le plus récent (enfant) d'abord String(b.row.id).localeCompare(String(a.row.id)) // filet de sécurité ) .map(e => e.row); } /* ── Solde indicatif du porte-monnaie, ligne par ligne ──────────────────────────── Calcule, pour CHAQUE mouvement d'une plateforme, le solde du porte-monnaie de cette plateforme immédiatement après ce mouvement — à titre indicatif (aide à repérer des anomalies visuellement), PAS une source de vérité chiffrée : c'est une somme cumulée de signedMontant() dans l'ordre chronologique probable (cf. sortMouvements ci-dessus, lui-même basé sur des règles de départage à date égale qui restent des approximations en l'absence d'horodatage précis dans les données). IMPORTANT : `list` DOIT être la liste COMPLÈTE et NON FILTRÉE (aucun filtre année/mois/type) des mouvements du scope investisseur courant — sinon le cumul démarrerait arbitrairement au milieu de l'historique et le solde n'aurait aucun sens. Le filtre plateforme n'a en revanche pas d'importance : le calcul est de toute façon fait indépendamment pour chaque plateforme. Ne touche PAS aux formules officielles du solde porte-monnaie (kpiSoldePortefeuille / computeSoldePortefeuille / walletMap, cf. [[project_solde_portefeuille]] et [[project_mouvements_tab_wallet_events]]) : ce solde-ci est un indicateur d'affichage indépendant, recalculé à partir des mêmes lignes déjà utilisées pour l'affichage et l'export — jamais l'inverse. Retourne une Map id de ligne → solde après cette ligne. Décidé avec Olivier le 2026-08-21. ── */ export function computeSoldeIndicatifMap(list) { const byPlateforme = new Map(); for (const row of list) { const key = row.plateforme_id ?? row.plateforme_nom ?? '?'; if (!byPlateforme.has(key)) byPlateforme.set(key, []); byPlateforme.get(key).push(row); } const soldeById = new Map(); for (const rows of byPlateforme.values()) { // sortMouvements() trie du plus récent au plus ancien ; on inverse pour cumuler // dans l'ordre chronologique probable (du plus ancien au plus récent). const chrono = sortMouvements(rows).slice().reverse(); let running = 0; for (const row of chrono) { running += signedMontant(row); soldeById.set(row.id, running); } } return soldeById; } export function mouvToCSV(rows) { const BOM = ''; const sep = ';'; const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Solde porte-monnaie indicatif (€)','Libellé','Référence','Source']; const data = rows.map(r => [ r.date_operation, r.plateforme_nom||'', MOUV_TYPE_LABELS[r.type] || r.type, r._projet||'', String(signedMontant(r)).replace('.',','), r._soldeIndicatif != null ? String(r._soldeIndicatif).replace('.',',') : '', r.libelle||'', r.reference||'', r.source||'', ]); return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n'); } export 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': MOUV_TYPE_LABELS[r.type] || r.type, 'Projet': r._projet || '', 'Montant (€)': signedMontant(r), 'Solde porte-monnaie indicatif (€)': r._soldeIndicatif ?? '', })); const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Mouvements'); return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); } export function mouvToJSON(rows) { const data = rows.map(r => ({ date_operation: r.date_operation, plateforme_nom: r.plateforme_nom || '', type: r.type, projet: r._projet || null, montant: signedMontant(r), solde_indicatif: r._soldeIndicatif ?? null, libelle: r.libelle || null, reference: r.reference || null, source: r.source || null, })); return JSON.stringify(data, null, 2); } /* ── Constantes ──────────────────────────────────────────────── */ export const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; export const emptyMouvementForm = { investisseur_id: '', plateforme_id: '', date_operation: today(), type: 'depot', montant: '', libelle: '', reference: '', notes: '' }; /* ── Menu déroulant d'export CSV/XLS/JSON ────────────────────── */ export function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); const choose = fn => { setOpen(false); fn(); }; return (
{open && (
)}
); } /* ── Menu déroulant "+" — actions rapides sur les mouvements (extensible : pour l'instant uniquement la création d'une correction de solde) ── */ export function AddDropdown({ disabled, onNewCorrection }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); const choose = fn => { setOpen(false); fn(); }; return (
{open && (
)}
); } /** Petite modale de sélection plateforme + date, avant d'ouvrir CorrectionModal en saisie libre (déclenchée depuis AddDropdown, pas depuis un mouvement existant). */ export function CorrectionPickerModal({ open, onClose, onContinue, plateformes = [], multiDetenteur, defaultPlateformeId }) { const [platId, setPlatId] = useState(''); const [date, setDate] = useState(''); useEffect(() => { if (open) { setPlatId(defaultPlateformeId ? String(defaultPlateformeId) : ''); setDate(today()); } }, [open, defaultPlateformeId]); if (!open) return null; return (
Nouvelle correction de solde
Choisissez la plateforme et la date de référence pour la correction.
setDate(e.target.value)} style={{ width: '100%' }} />
); } /* ── Panneau de détail d'un mouvement ────────────────────────── */ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvestissement }) { if (!row) return (
Sélectionnez un mouvement
); const isCorrection = row.type === 'correction'; const isBonus = row.type === 'bonus'; const isCashback = row.type === 'cashback'; const isInteretsPlateforme = row.type === 'interets_plateforme'; const isRemb = row.type === 'remboursement' || isBonus || isCashback || isInteretsPlateforme; const isSouscription = row.type === 'souscription'; const isPrelevement = row.type === 'prelevement_ir' || row.type === 'prelevement_csg' || row.type === 'prelevement_local'; const isFrais = row.type === 'frais'; const isFraisHorsPM = isFrais && row.mode_reglement === 'compte_courant'; const isFraisRembLinked = isFrais && row.mode_reglement === 'remboursement'; const isAuto = row.source === 'auto_remboursement'; const typeLabel = isCorrection ? 'Correction de solde' : isBonus ? 'Bonus' : isCashback ? 'Cashback' : row.type === 'remboursement' ? 'Remboursement (porte-monnaie, brut)' : isInteretsPlateforme ? 'Intérêts plateforme (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') : isFrais ? "Frais d'opération" + (isFraisHorsPM ? ' (hors porte-monnaie)' : isFraisRembLinked ? ' (déduit du remboursement)' : '') : 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/prélèvements, crédit (+) pour dépôt/remboursement/bonus/cashback ; // la correction est déjà signée en base. const isDebitType = row.type === 'retrait' || isSouscription || isPrelevement || isFrais; const isPos = isCorrection ? row.montant >= 0 : !isDebitType; const sign = isPos ? '+' : '−'; const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`; const montantColor = undefined; const isRembOnly = row.type === 'remboursement' || isInteretsPlateforme; const fields = [ { label: 'Plateforme', value: row.plateforme_nom }, { label: 'Type de mouvement', value: typeLabel }, { label: 'Montant', value: montantFmt, color: montantColor }, { label: 'Date', value: fmtDate(row.date_operation) }, isRembOnly && row.libelle && { label: 'Projet', value: row.libelle }, (isBonus || isCashback) && row.libelle && { label: 'Projet', value: row.libelle }, isRembOnly && !isInteretsPlateforme && { label: 'Capital', value: fmtEUR(row._capital) }, isRembOnly && row._cashback > 0 && { label: 'Cashback', value: fmtEUR(row._cashback) }, isRembOnly && { label: 'Intérêts bruts', value: fmtEUR(row._interets_bruts) }, isRembOnly && { 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 }, isFrais && row.libelle && { label: 'Projet', value: row.libelle }, isFrais && { label: 'Mode de règlement', value: FRAIS_MODE_LABELS[row.mode_reglement] || row.mode_reglement }, isFrais && row.mode_reglement === 'compte_courant' && row.compte_nom && { label: 'Compte', value: row.compte_nom }, isFrais && row.categorie && { label: 'Catégorie', value: FRAIS_CATEGORIE_LABELS[row.categorie] || row.categorie }, isFrais && row.origine && { label: 'Origine', value: FRAIS_ORIGINE_LABELS[row.origine] || row.origine }, !isCorrection && !isRemb && !isSouscription && !isPrelevement && !isFrais && 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); return (
Détails du mouvement
{isAuto && (
Retrait automatique — généré lors d'un remboursement sur compte courant. Modifiable via la page Remboursements ou depuis la fiche détail de l'investissement.
)} {isRemb && (
{isBonus ? 'Bonus crédité directement au porte-monnaie de la plateforme.' : isCashback ? "Cashback crédité au porte-monnaie de la plateforme, saisi sur un remboursement classique plutôt qu'en tant que bonus. Modifiable depuis la page Remboursements." : isInteretsPlateforme ? "Intérêts versés par la plateforme, hors investissement précis — crédités au porte-monnaie (montant brut). Les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements." : row._isAutoSweep ? "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."}
)} {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."}
)} {isFrais && (
{isFraisHorsPM ? "Frais réglé via un compte courant du détenteur — n'a jamais d'effet sur le solde du porte-monnaie de la plateforme, affiché ici à titre de traçabilité uniquement." : isFraisRembLinked ? "Frais de distribution déduit du coupon brut de ce remboursement, avant calcul des prélèvements — réduit d'autant le montant net reçu (cf. avis d'opéré de la plateforme)." : row.mode_reglement === 'source' ? "Frais prélevé à la source par la plateforme sur le montant transféré — sort du porte-monnaie en plus du capital net déjà déduit de la souscription." : "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."} {' '}{isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
)}
{fields.map(f => (
{f.label} {f.value}
))}
{isAuto ? null : (isRemb || isPrelevement) ? ( ) : isFraisRembLinked ? ( ) : isSouscription || isFrais ? ( ) : isCorrection ? ( ) : ( )}
); } /** Modal de correction de solde porte-monnaie (micro-écarts flat_tax) */ export function CorrectionModal({ open, onClose, onConfirm, plateforme_nom, type_operation, computedBalance, date }) { const isRetrait = type_operation === 'retrait'; // Défaut : 0 pour un retrait (vider le PM), solde calculé pour un dépôt const [declared, setDeclared] = useState(''); useEffect(() => { if (open) setDeclared(isRetrait ? '0' : String(Math.round(computedBalance * 100) / 100)); }, [open, isRetrait, computedBalance]); const diff = Math.round((Number(declared) - computedBalance) * 100) / 100; const hasDiff = Math.abs(diff) >= 0.005; // Anti-double-soumission (cf. useAsyncAction) : évite de créer deux fois la même correction // sur un double-clic / une connexion lente, avant que la modale ne se ferme. const { run, pending } = useAsyncAction(); const handleConfirm = () => run(() => onConfirm(hasDiff ? diff : 0)); if (!open) return null; return (
Vérification du solde porte-monnaie
{type_operation ? ( <>Après ce {isRetrait ? 'retrait' : 'dépôt'} sur {plateforme_nom}, quel est le solde indiqué sur la plateforme ? ) : ( <>Quel est le solde indiqué sur {plateforme_nom} à la date du {date ? fmtDate(date) : ''} ? )} {' '}Si différent du solde calculé, une correction sera créée automatiquement.
Solde calculé {Math.round(computedBalance * 100) / 100} €
setDeclared(e.target.value)} style={{ width: '100%', fontSize: 'var(--fs-base)' }} autoFocus />
{hasDiff && (
0 ? 'color-mix(in srgb, var(--success) 12%, transparent)' : 'color-mix(in srgb, var(--danger) 12%, transparent)', border: `1px solid ${diff > 0 ? 'color-mix(in srgb, var(--success) 30%, transparent)' : 'color-mix(in srgb, var(--danger) 30%, transparent)'}`, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', }}> Une correction de 0 ? 'var(--success)' : 'var(--danger)' }}> {diff > 0 ? '+' : ''}{diff} € sera créée.
)} {!hasDiff && declared !== '' && (
Solde identique au calcul — aucune correction nécessaire.
)}
); }