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). // Frais PE prélevés "à la source" (19/09/26, confirmé par Olivier) : contrairement au // crowdlending où montant_investi est le capital NET (frais 'source' = débit supplémentaire // légitime, cf. commentaire ci-dessus), côté PE le montant_investi affiché en ligne // "souscription" est déjà le montant BRUT transféré à la plateforme, frais compris — un // frais 'source' PE n'est donc PAS un débit supplémentaire du porte-monnaie, sous peine de // compter deux fois la même sortie d'argent. Affiché quand même (traçabilité), à 0 d'effet, // comme 'compte_courant'. Repéré via r._investType === 'pe' (posé par Plateformes.jsx / // mvNormalizedPeMouvements) plutôt que par plateforme : ne dépend pas de la plateforme, mais // du modèle de données PE lui-même (toujours vrai pour tout deal PE). if (r.type === 'frais') { if (r.mode_reglement === 'compte_courant') return 0; if (r._investType === 'pe' && r.mode_reglement === 'source') return 0; 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); } 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 (