Mise en place de la feature de gestion des Frais
This commit is contained in:
1 parent
9ea078302d
commit
0334a63139
27 files changed
+2401
-117
No files matched your search
@@ -28,6 +28,19 @@ export const DEBIT_TYPES = new Set(['retrait', 'souscription', 'prelevement_ir',
|
||||
// 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);
|
||||
@@ -39,8 +52,19 @@ export const MOUV_TYPE_LABELS = {
|
||||
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
|
||||
@@ -96,7 +120,7 @@ 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,
|
||||
souscription: 3, frais: 3,
|
||||
retrait: 4,
|
||||
correction: 5,
|
||||
};
|
||||
@@ -104,7 +128,12 @@ const MOUV_FAMILY_RANK = {
|
||||
// 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,
|
||||
cashback: 1, prelevement_local: 2, prelevement_ir: 3, prelevement_csg: 4,
|
||||
// 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) {
|
||||
@@ -124,7 +153,7 @@ export function sortMouvements(list) {
|
||||
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: 5 };
|
||||
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é.
|
||||
@@ -132,6 +161,15 @@ export function sortMouvements(list) {
|
||||
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 {
|
||||
@@ -141,6 +179,14 @@ export function sortMouvements(list) {
|
||||
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 };
|
||||
@@ -413,6 +459,9 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
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'
|
||||
@@ -424,6 +473,7 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
: 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'
|
||||
@@ -431,7 +481,7 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
|
||||
// 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;
|
||||
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))}`;
|
||||
@@ -456,7 +506,12 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
: 'É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 && !isPrelevement && row.libelle && { label: 'Libellé', 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);
|
||||
@@ -562,6 +617,34 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFrais && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
margin: '5px 5px 12px',
|
||||
padding: '9px 12px', borderRadius: 6,
|
||||
background: `color-mix(in srgb, ${isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b'} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b'} 25%, transparent)`,
|
||||
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ marginTop: 1, flexShrink: 0, color: isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b' }}>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<span>
|
||||
{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."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dr-detail-fields">
|
||||
{fields.map(f => (
|
||||
<div className="dr-detail-field" key={f.label}>
|
||||
@@ -576,7 +659,11 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
|
||||
Voir / modifier le remboursement
|
||||
</button>
|
||||
) : isSouscription ? (
|
||||
) : isFraisRembLinked ? (
|
||||
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
|
||||
Voir / modifier le remboursement
|
||||
</button>
|
||||
) : isSouscription || isFrais ? (
|
||||
<button className="dr-detail-edit-btn" onClick={() => onViewInvestissement(row)}>
|
||||
Voir l'investissement
|
||||
</button>
|
||||
|
||||
Reference in new issue
Block a user