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
@@ -2613,4 +2613,118 @@ if (!smtpCols.includes('documents_quota_count')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Migration : frais d'opération (Étape 1 — fondations) ────────────────────
|
||||||
|
// Modélise les frais facturés par certaines plateformes (frais d'entrée,
|
||||||
|
// frais de gestion, frais sur distribution) : un montant TTC unique par
|
||||||
|
// événement (pas de détail HT/TVA, non pertinent pour un particulier).
|
||||||
|
// - remboursements.frais_ttc : montant de frais prélevé sur CE coupon,
|
||||||
|
// vient réduire la base imposable au même titre que taxe_locale
|
||||||
|
// (imposition = f(interets_bruts - frais_ttc)), cf. feedback Olivier
|
||||||
|
// 30/08/26 sur l'avis d'opéré/notice de distribution Fundora (Kyoseil AM).
|
||||||
|
// - frais_operations : table de traçabilité pour tout frais, qu'il soit
|
||||||
|
// prélevé "à la source" (souscription ou coupon, aucun mouvement de
|
||||||
|
// trésorerie réel) ou payé en "versement complémentaire" (porte-monnaie
|
||||||
|
// de la plateforme ou compte courant du détenteur — dans ce dernier cas
|
||||||
|
// sans impact sur le solde du porte-monnaie). Pas encore d'UI ni de route
|
||||||
|
// de création automatique à ce stade : fondations DB + formule fiscale
|
||||||
|
// uniquement, le reste (étapes souscription/remboursement/modale dédiée)
|
||||||
|
// viendra dans une prochaine passe.
|
||||||
|
{
|
||||||
|
const rembColsFraisTtc = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
|
||||||
|
if (!rembColsFraisTtc.includes('frais_ttc')) {
|
||||||
|
db.exec('ALTER TABLE remboursements ADD COLUMN frais_ttc REAL NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS frais_operations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
|
||||||
|
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||||
|
remboursement_id INTEGER REFERENCES remboursements(id) ON DELETE CASCADE,
|
||||||
|
montant REAL NOT NULL,
|
||||||
|
mode_reglement TEXT NOT NULL DEFAULT 'source'
|
||||||
|
CHECK(mode_reglement IN ('source','portefeuille','compte_courant')),
|
||||||
|
compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL,
|
||||||
|
date_operation TEXT NOT NULL,
|
||||||
|
origine TEXT NOT NULL DEFAULT 'manuel'
|
||||||
|
CHECK(origine IN ('souscription','remboursement','manuel')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
|
||||||
|
|
||||||
|
// ── Migration : catégories de frais (31/08/26, demande Olivier) ─────────────
|
||||||
|
// `origine` (souscription/remboursement/manuel) reste un détail technique (comment la
|
||||||
|
// ligne a été créée) ; `categorie` devient le classement visible côté utilisateur, utile
|
||||||
|
// pour le reporting "Frais payés" de l'Étape 5 : souscription (frais d'entrée, unique par
|
||||||
|
// investissement — lu/écrit uniquement depuis l'étape "Frais" du formulaire de
|
||||||
|
// création/modification) / gestion / distribution (liée à un remboursement) / autre
|
||||||
|
// (saisie libre depuis le bloc "Frais d'opération" de la fiche investissement).
|
||||||
|
{
|
||||||
|
const fraisOpCols = db.prepare('PRAGMA table_info(frais_operations)').all().map(c => c.name);
|
||||||
|
if (!fraisOpCols.includes('categorie')) {
|
||||||
|
db.exec("ALTER TABLE frais_operations ADD COLUMN categorie TEXT NOT NULL DEFAULT 'autre'");
|
||||||
|
// Rattrapage pour les lignes déjà créées avant l'ajout de cette colonne (tests Olivier,
|
||||||
|
// 30-31/08/26) : on déduit la catégorie la plus probable depuis `origine`.
|
||||||
|
db.exec(`
|
||||||
|
UPDATE frais_operations SET categorie = CASE origine
|
||||||
|
WHEN 'souscription' THEN 'souscription'
|
||||||
|
WHEN 'remboursement' THEN 'distribution'
|
||||||
|
ELSE 'autre'
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migration : mode_reglement 'remboursement' (frais de distribution liés
|
||||||
|
// automatiquement à un remboursement — Étape 4, 31/08/26) ───────────────────
|
||||||
|
// SQLite ne permet pas d'altérer un CHECK constraint en place (better-sqlite3
|
||||||
|
// bloque writable_schema) → même stratégie de reconstruction de table que pour
|
||||||
|
// investissements/plateformes/remboursements ailleurs dans ce fichier.
|
||||||
|
{
|
||||||
|
const schemaFraisOp = db.prepare(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='frais_operations'"
|
||||||
|
).get()?.sql ?? '';
|
||||||
|
|
||||||
|
if (schemaFraisOp.includes("mode_reglement IN ('source','portefeuille','compte_courant')")) {
|
||||||
|
const tempName = '__repair_frais_operations';
|
||||||
|
|
||||||
|
const fixedDdl = schemaFraisOp
|
||||||
|
.replace(/CREATE TABLE frais_operations\b/, `CREATE TABLE "${tempName}"`)
|
||||||
|
.replace(
|
||||||
|
"mode_reglement IN ('source','portefeuille','compte_courant')",
|
||||||
|
"mode_reglement IN ('source','portefeuille','compte_courant','remboursement')",
|
||||||
|
);
|
||||||
|
|
||||||
|
const colDefs = db.prepare('PRAGMA table_info(frais_operations)').all();
|
||||||
|
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
|
||||||
|
const idxs = db.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='frais_operations' AND sql IS NOT NULL"
|
||||||
|
).all();
|
||||||
|
|
||||||
|
db.exec('PRAGMA foreign_keys = OFF');
|
||||||
|
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
|
||||||
|
db.exec(fixedDdl);
|
||||||
|
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM frais_operations`);
|
||||||
|
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||||
|
db.exec('DROP TABLE frais_operations');
|
||||||
|
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
|
||||||
|
db.exec('PRAGMA legacy_alter_table = ON');
|
||||||
|
db.exec(`ALTER TABLE "${tempName}" RENAME TO frais_operations`);
|
||||||
|
db.exec('PRAGMA legacy_alter_table = OFF');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
@@ -125,6 +125,23 @@ router.get('/', (req, res) => {
|
|||||||
GROUP BY i.plateforme_id
|
GROUP BY i.plateforme_id
|
||||||
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
|
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
|
||||||
|
|
||||||
|
// Frais d'opération (Étape 3, 31/08/26) : un frais réglé 'source' ou 'portefeuille'
|
||||||
|
// réduit le solde du porte-monnaie. 'source' s'ajoute à la réduction déjà appliquée via
|
||||||
|
// capitalInvestiPerPlat (qui soustrait montant_investi déjà net) — cas confirmé par Olivier
|
||||||
|
// (Fundora, 31/08/26) où la plateforme débite le montant BRUT, pas seulement le net investi.
|
||||||
|
// 'portefeuille' est un versement complémentaire distinct de montant_investi, jamais câblé
|
||||||
|
// jusqu'ici. 'compte_courant' reste exclu (aucun impact sur le porte-monnaie).
|
||||||
|
const fraisPerPlat = db.prepare(`
|
||||||
|
SELECT f.plateforme_id,
|
||||||
|
COALESCE(SUM(f.montant), 0) AS total_frais
|
||||||
|
FROM frais_operations f
|
||||||
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
|
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||||
|
AND f.mode_reglement IN ('source', 'portefeuille')
|
||||||
|
${asOf ? 'AND f.date_operation <= ?' : ''}
|
||||||
|
GROUP BY f.plateforme_id
|
||||||
|
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||||
|
|
||||||
// Corrections de solde (micro-ecarts fiscaux sur plateformes flat_tax)
|
// Corrections de solde (micro-ecarts fiscaux sur plateformes flat_tax)
|
||||||
const correctionsPerPlat = db.prepare(`
|
const correctionsPerPlat = db.prepare(`
|
||||||
SELECT plateforme_id,
|
SELECT plateforme_id,
|
||||||
@@ -141,6 +158,7 @@ router.get('/', (req, res) => {
|
|||||||
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
|
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
|
||||||
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
|
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
|
||||||
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||||
|
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
|
||||||
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
|
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
|
||||||
|
|
||||||
const cashByPlatformEnriched = cashByPlatform.map(p => ({
|
const cashByPlatformEnriched = cashByPlatform.map(p => ({
|
||||||
@@ -340,7 +358,18 @@ router.get('/solde-historique', (req, res) => {
|
|||||||
AND i.date_souscription <= ?
|
AND i.date_souscription <= ?
|
||||||
`).get(date, platId, ...invParams, date);
|
`).get(date, platId, ...invParams, date);
|
||||||
|
|
||||||
/* ── 5. Corrections de solde (jusqu'à :date) ───────────────────── */
|
/* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */
|
||||||
|
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant.
|
||||||
|
const frais = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(f.montant), 0) AS total_frais
|
||||||
|
FROM frais_operations f
|
||||||
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
|
WHERE i.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||||
|
AND f.mode_reglement IN ('source', 'portefeuille')
|
||||||
|
AND f.date_operation <= ?
|
||||||
|
`).get(platId, ...invParams, date);
|
||||||
|
|
||||||
|
/* ── 6. Corrections de solde (jusqu'à :date) ───────────────────── */
|
||||||
const corrections = db.prepare(`
|
const corrections = db.prepare(`
|
||||||
SELECT COALESCE(SUM(montant), 0) AS total_correction
|
SELECT COALESCE(SUM(montant), 0) AS total_correction
|
||||||
FROM corrections_solde
|
FROM corrections_solde
|
||||||
@@ -353,6 +382,7 @@ router.get('/solde-historique', (req, res) => {
|
|||||||
+ remb.remb_wallet
|
+ remb.remb_wallet
|
||||||
+ bonus.bonus_wallet
|
+ bonus.bonus_wallet
|
||||||
- capital.capital_investi
|
- capital.capital_investi
|
||||||
|
- frais.total_frais
|
||||||
+ corrections.total_correction
|
+ corrections.total_correction
|
||||||
) * 100) / 100;
|
) * 100) / 100;
|
||||||
|
|
||||||
@@ -923,11 +953,14 @@ router.get('/detail-cellule', (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
r.id, r.investissement_id, r.date_remb,
|
r.id, r.investissement_id, r.date_remb,
|
||||||
r.capital, r.cashback,
|
r.capital, r.cashback,
|
||||||
r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
|
r.interets_bruts, r.frais_ttc, r.prelev_sociaux, r.prelev_forfaitaire,
|
||||||
r.interets_nets, r.net_recu,
|
r.interets_nets, r.net_recu,
|
||||||
i.nom_projet, i.plateforme_id,
|
i.nom_projet, i.plateforme_id,
|
||||||
p.nom AS plateforme_nom,
|
p.nom AS plateforme_nom,
|
||||||
inv.nom AS detenteur_nom
|
inv.nom AS detenteur_nom,
|
||||||
|
-- Étape 5 bis, 01/09/26 : le frais n'est pas toujours réglé SUR ce remboursement (cf.
|
||||||
|
-- remboursements.js, computeChamps/syncFraisDistribution) — pilote l'icône/popup de détail.
|
||||||
|
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement
|
||||||
FROM remboursements r
|
FROM remboursements r
|
||||||
JOIN investissements i ON i.id = r.investissement_id
|
JOIN investissements i ON i.id = r.investissement_id
|
||||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import db from '../db/index.js';
|
||||||
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* frais_operations — traçabilité des frais facturés par certaines plateformes
|
||||||
|
* (frais d'entrée, frais de gestion, frais sur distribution), qu'ils soient
|
||||||
|
* prélevés "à la source" (aucun mouvement de trésorerie réel — le montant
|
||||||
|
* est simplement documenté à titre de traçabilité) ou payés en "versement
|
||||||
|
* complémentaire" via le porte-monnaie de la plateforme ou un compte courant
|
||||||
|
* du détenteur (dans ce dernier cas, sans impact sur le solde du porte-monnaie).
|
||||||
|
*
|
||||||
|
* Montant stocké : TTC uniquement (pas de détail HT/TVA, non pertinent pour
|
||||||
|
* un particulier — cf. échange avec Olivier, 30/08/26).
|
||||||
|
*
|
||||||
|
* Étape 1 (fondations) : ce module fournit un CRUD complet, mais rien dans
|
||||||
|
* l'application ne crée encore de frais_operations automatiquement — la
|
||||||
|
* saisie à la souscription, la modale dédiée et l'intégration aux Mouvements
|
||||||
|
* viendront dans une prochaine passe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
investissement_id: z.number().int().positive(),
|
||||||
|
remboursement_id: z.number().int().positive().nullable().optional(),
|
||||||
|
montant: z.number().positive(),
|
||||||
|
mode_reglement: z.enum(['source', 'portefeuille', 'compte_courant']).default('source'),
|
||||||
|
compte_id: z.number().int().positive().nullable().optional(),
|
||||||
|
date_operation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
origine: z.enum(['souscription', 'remboursement', 'manuel']).default('manuel'),
|
||||||
|
// Classement visible côté utilisateur (31/08/26, demande Olivier) — distinct de `origine`
|
||||||
|
// (qui reste un détail technique de création). 'souscription' est réservée à l'étape
|
||||||
|
// "Frais" du formulaire d'investissement (unique par investissement, cf. assertUniqueSouscription).
|
||||||
|
categorie: z.enum(['souscription', 'gestion', 'distribution', 'autre']).default('autre'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertOwnedInvestissement(invId, userId) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT i.id, i.plateforme_id FROM investissements i
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
||||||
|
WHERE i.id = ?
|
||||||
|
`).get(userId, invId);
|
||||||
|
if (!row) throw new HttpError(403, 'Investissement not in scope');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRemboursementBelongs(rembId, invId) {
|
||||||
|
if (!rembId) return;
|
||||||
|
const remb = db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?')
|
||||||
|
.get(rembId, invId);
|
||||||
|
if (!remb) throw new HttpError(400, "remboursement_id ne correspond pas à cet investissement");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertOwnedCompte(compteId, userId) {
|
||||||
|
if (!compteId) return;
|
||||||
|
const compte = db.prepare('SELECT id FROM comptes WHERE id = ? AND user_id = ?').get(compteId, userId);
|
||||||
|
if (!compte) throw new HttpError(403, 'Compte inconnu ou non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un investissement ne peut avoir qu'UNE seule entrée de catégorie 'souscription' — c'est
|
||||||
|
* l'entrée lue/écrite depuis l'étape "Frais" du formulaire de création/modification de
|
||||||
|
* l'investissement (jamais depuis le bloc "Frais d'opération" de la fiche, qui exclut cette
|
||||||
|
* catégorie de son sélecteur). `excludeId` : à passer lors d'un PUT pour ne pas se bloquer
|
||||||
|
* soi-même.
|
||||||
|
*/
|
||||||
|
function assertUniqueSouscription(investissementId, categorie, excludeId) {
|
||||||
|
if (categorie !== 'souscription') return;
|
||||||
|
const existing = excludeId
|
||||||
|
? db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription' AND id != ?`).get(investissementId, excludeId)
|
||||||
|
: db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription'`).get(investissementId);
|
||||||
|
if (existing) throw new HttpError(409, 'Un frais de catégorie "souscription" existe déjà pour cet investissement');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/frais-operations?investissement_id=123
|
||||||
|
* Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs).
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { investissement_id } = req.query;
|
||||||
|
let rows;
|
||||||
|
if (investissement_id) {
|
||||||
|
assertOwnedInvestissement(Number(investissement_id), req.user.id);
|
||||||
|
rows = db.prepare(`
|
||||||
|
SELECT f.*, c.nom AS compte_nom
|
||||||
|
FROM frais_operations f
|
||||||
|
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||||
|
WHERE f.investissement_id = ?
|
||||||
|
ORDER BY f.date_operation DESC, f.id DESC
|
||||||
|
`).all(Number(investissement_id));
|
||||||
|
} else {
|
||||||
|
rows = db.prepare(`
|
||||||
|
SELECT f.*, c.nom AS compte_nom
|
||||||
|
FROM frais_operations f
|
||||||
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||||
|
WHERE inv.user_id = ?
|
||||||
|
ORDER BY f.date_operation DESC, f.id DESC
|
||||||
|
`).all(req.user.id);
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = Schema.parse(req.body);
|
||||||
|
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||||
|
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||||
|
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||||
|
assertUniqueSouscription(body.investissement_id, body.categorie);
|
||||||
|
|
||||||
|
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||||
|
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO frais_operations
|
||||||
|
(investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
`).run(
|
||||||
|
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||||
|
body.montant, body.mode_reglement, compteId,
|
||||||
|
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: r.lastInsertRowid, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const existing = db.prepare(`
|
||||||
|
SELECT f.id FROM frais_operations f
|
||||||
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
WHERE f.id = ? AND inv.user_id = ?
|
||||||
|
`).get(id, req.user.id);
|
||||||
|
if (!existing) throw new HttpError(404, 'Not found');
|
||||||
|
|
||||||
|
const body = Schema.parse(req.body);
|
||||||
|
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||||
|
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||||
|
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||||
|
assertUniqueSouscription(body.investissement_id, body.categorie, id);
|
||||||
|
|
||||||
|
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE frais_operations
|
||||||
|
SET investissement_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
|
||||||
|
compte_id=?, date_operation=?, origine=?, categorie=?, notes=?, updated_at=datetime('now')
|
||||||
|
WHERE id=?
|
||||||
|
`).run(
|
||||||
|
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||||
|
body.montant, body.mode_reglement, compteId,
|
||||||
|
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
res.json({ id, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const existing = db.prepare(`
|
||||||
|
SELECT f.id FROM frais_operations f
|
||||||
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
WHERE f.id = ? AND inv.user_id = ?
|
||||||
|
`).get(id, req.user.id);
|
||||||
|
if (!existing) throw new HttpError(404, 'Not found');
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM frais_operations WHERE id = ?').run(id);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -4,6 +4,7 @@ import db from '../db/index.js';
|
|||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||||
import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
||||||
|
import { assertOwnedCompte } from './fraisOperations.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -18,6 +19,14 @@ const Schema = z.object({
|
|||||||
interets_bruts_avant_local: z.number().nonnegative().default(0),
|
interets_bruts_avant_local: z.number().nonnegative().default(0),
|
||||||
taxe_locale: z.number().nonnegative().default(0),
|
taxe_locale: z.number().nonnegative().default(0),
|
||||||
interets_bruts: z.number().nonnegative().default(0),
|
interets_bruts: z.number().nonnegative().default(0),
|
||||||
|
frais_ttc: z.number().nonnegative().default(0),
|
||||||
|
// Prélèvement du frais (Étape 5 bis, 01/09/26 — cas Baltis remonté par Olivier) : par défaut
|
||||||
|
// 'remboursement' (comportement historique, Étape 4) = le frais est retenu SUR ce versement
|
||||||
|
// (réduit la base imposable ET net_recu) ; 'portefeuille'/'compte_courant' = le frais est réglé
|
||||||
|
// à part (comme pour la souscription) — aucun impact sur l'imposition ni le montant versé de CE
|
||||||
|
// remboursement, cf. computeChamps() plus bas. cf. project_frais_operation.md.
|
||||||
|
frais_mode_reglement: z.enum(['remboursement', 'portefeuille', 'compte_courant']).default('remboursement'),
|
||||||
|
frais_compte_id: z.number().int().positive().nullable().optional(),
|
||||||
prelev_sociaux: z.number().nonnegative().default(0),
|
prelev_sociaux: z.number().nonnegative().default(0),
|
||||||
prelev_forfaitaire: z.number().nonnegative().default(0),
|
prelev_forfaitaire: z.number().nonnegative().default(0),
|
||||||
statut: z.enum(['paye','retard','partiel','impaye']).default('paye'),
|
statut: z.enum(['paye','retard','partiel','impaye']).default('paye'),
|
||||||
@@ -37,9 +46,20 @@ const Schema = z.object({
|
|||||||
* les prélèvements sont retenus à la source, net_recu = capital + cashback + interets_nets.
|
* les prélèvements sont retenus à la source, net_recu = capital + cashback + interets_nets.
|
||||||
*/
|
*/
|
||||||
function computeChamps(body, isTaxIndicatif = false) {
|
function computeChamps(body, isTaxIndicatif = false) {
|
||||||
const interets_nets = Math.round((body.interets_bruts - body.prelev_sociaux - body.prelev_forfaitaire) * 100) / 100;
|
// Le frais ne réduit la base imposable (et donc net_recu) que lorsqu'il est réellement
|
||||||
|
// RETENU SUR ce remboursement (mode 'remboursement', comportement historique de l'Étape 4).
|
||||||
|
// Un frais réglé à part ('portefeuille'/'compte_courant', Étape 5 bis, 01/09/26 — cas Baltis)
|
||||||
|
// n'a aucun impact ici : la plateforme calcule ses prélèvements sur l'intérêt brut complet, et
|
||||||
|
// le frais est une sortie d'argent séparée, tracée dans frais_operations mais sans effet sur
|
||||||
|
// l'imposition ou le montant versé de CE remboursement — cf. project_frais_operation.md.
|
||||||
|
const fraisReduitBase = !body.frais_mode_reglement || body.frais_mode_reglement === 'remboursement';
|
||||||
|
const fraisTtc = fraisReduitBase ? (body.frais_ttc || 0) : 0;
|
||||||
|
// Base imposable nette de frais : imposition = f(interets_bruts - frais_ttc),
|
||||||
|
// cf. avis d'opéré / notice de distribution Kyoseil-AM (Fundora) — Olivier, 30/08/26.
|
||||||
|
const baseNetteDeFrais = Math.round(((body.interets_bruts || 0) - fraisTtc) * 100) / 100;
|
||||||
|
const interets_nets = Math.round((baseNetteDeFrais - body.prelev_sociaux - body.prelev_forfaitaire) * 100) / 100;
|
||||||
const net_recu = isTaxIndicatif
|
const net_recu = isTaxIndicatif
|
||||||
? Math.round(((body.capital || 0) + (body.cashback || 0) + (body.interets_bruts || 0)) * 100) / 100
|
? Math.round(((body.capital || 0) + (body.cashback || 0) + baseNetteDeFrais) * 100) / 100
|
||||||
: Math.round(((body.capital || 0) + (body.cashback || 0) + interets_nets) * 100) / 100;
|
: Math.round(((body.capital || 0) + (body.cashback || 0) + interets_nets) * 100) / 100;
|
||||||
return { interets_nets, net_recu };
|
return { interets_nets, net_recu };
|
||||||
}
|
}
|
||||||
@@ -93,6 +113,48 @@ function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise la ligne frais_operations (catégorie 'distribution') liée à CE remboursement
|
||||||
|
* avec son champ frais_ttc — Étape 4, 31/08/26. Depuis l'Étape 5 bis (01/09/26, cas Baltis
|
||||||
|
* remonté par Olivier), le mode_reglement de cette ligne n'est plus figé à 'remboursement' :
|
||||||
|
* l'utilisateur choisit, comme pour le frais de souscription, si le frais est retenu SUR ce
|
||||||
|
* remboursement ('remboursement' — déjà intégralement pris en compte dans net_recu, cf.
|
||||||
|
* computeChamps, donc EXCLU du calcul du solde du porte-monnaie dans dashboard.js pour ne pas
|
||||||
|
* le déduire deux fois) ou réglé à part ('portefeuille'/'compte_courant' — jamais netté nulle
|
||||||
|
* part ailleurs, donc dûment compté dans fraisPerPlat/le XIRR net comme n'importe quel autre
|
||||||
|
* frais de ce mode, cf. project_frais_operation.md).
|
||||||
|
* Upsert par remboursement_id (relation 1:1, quel que soit le mode) ; suppression si le montant
|
||||||
|
* est vidé. La suppression du remboursement lui-même cascade nativement (FK ON DELETE CASCADE).
|
||||||
|
* Uniquement pour les remboursements de type 'normal' (seuls à porter un investissement_id).
|
||||||
|
*/
|
||||||
|
function syncFraisDistribution(rembId, investissementId, plateformeId, fraisTtc, dateRemb, modeReglement = 'remboursement', compteId = null) {
|
||||||
|
const existing = db.prepare(
|
||||||
|
`SELECT id FROM frais_operations WHERE remboursement_id = ?`
|
||||||
|
).get(rembId);
|
||||||
|
|
||||||
|
if (!fraisTtc || fraisTtc <= 0) {
|
||||||
|
if (existing) db.prepare('DELETE FROM frais_operations WHERE id = ?').run(existing.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compte = modeReglement === 'compte_courant' ? (compteId ?? null) : null;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE frais_operations
|
||||||
|
SET investissement_id = ?, plateforme_id = ?, montant = ?, mode_reglement = ?, compte_id = ?,
|
||||||
|
date_operation = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(investissementId, plateformeId, fraisTtc, modeReglement, compte, dateRemb, existing.id);
|
||||||
|
} else {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO frais_operations
|
||||||
|
(investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'remboursement', 'distribution', NULL)
|
||||||
|
`).run(investissementId, plateformeId, rembId, fraisTtc, modeReglement, compte, dateRemb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function syncInvestissementStatut(investissement_id) {
|
export function syncInvestissementStatut(investissement_id) {
|
||||||
if (!investissement_id) return;
|
if (!investissement_id) return;
|
||||||
const inv = db.prepare('SELECT montant_investi FROM investissements WHERE id=?').get(investissement_id);
|
const inv = db.prepare('SELECT montant_investi FROM investissements WHERE id=?').get(investissement_id);
|
||||||
@@ -180,7 +242,11 @@ router.get('/', (req, res) => {
|
|||||||
p.nom AS plateforme_nom,
|
p.nom AS plateforme_nom,
|
||||||
inv.nom AS investisseur_nom,
|
inv.nom AS investisseur_nom,
|
||||||
plat_inv.nom AS plateforme_detenteur_nom,
|
plat_inv.nom AS plateforme_detenteur_nom,
|
||||||
c.nom AS compte_nom
|
c.nom AS compte_nom,
|
||||||
|
-- Sous-requête scalaire plutôt qu'un LEFT JOIN : frais_operations porte elle aussi des
|
||||||
|
-- colonnes investissement_id/plateforme_id, un JOIN les rendrait ambiguës pour outerWhere
|
||||||
|
-- (qui référence ces noms de colonnes sans préfixe de table) — Étape 5 bis, 01/09/26.
|
||||||
|
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = b.id) AS frais_mode_reglement
|
||||||
FROM base b
|
FROM base b
|
||||||
LEFT JOIN plateformes p ON p.id = b.plateforme_id
|
LEFT JOIN plateformes p ON p.id = b.plateforme_id
|
||||||
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
|
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
|
||||||
@@ -200,22 +266,30 @@ router.post('/', (req, res, next) => {
|
|||||||
|
|
||||||
if (body.type === 'normal') {
|
if (body.type === 'normal') {
|
||||||
assertOwnedInvestissement(body.investissement_id, req.user.id);
|
assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||||
|
if (body.frais_mode_reglement === 'compte_courant') assertOwnedCompte(body.frais_compte_id, req.user.id);
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO remboursements
|
INSERT INTO remboursements
|
||||||
(type, investissement_id, date_remb, capital, cashback,
|
(type, investissement_id, date_remb, capital, cashback,
|
||||||
interets_bruts_avant_local, taxe_locale, interets_bruts, prelev_sociaux,
|
interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc, prelev_sociaux,
|
||||||
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id)
|
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id)
|
||||||
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?)
|
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
body.investissement_id, body.date_remb, body.capital, body.cashback,
|
body.investissement_id, body.date_remb, body.capital, body.cashback,
|
||||||
body.interets_bruts_avant_local, body.taxe_locale,
|
body.interets_bruts_avant_local, body.taxe_locale,
|
||||||
body.interets_bruts, body.prelev_sociaux, body.prelev_forfaitaire,
|
body.interets_bruts, body.frais_ttc, body.prelev_sociaux, body.prelev_forfaitaire,
|
||||||
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
|
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
|
||||||
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
|
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
|
||||||
);
|
);
|
||||||
syncInvestissementStatut(body.investissement_id);
|
syncInvestissementStatut(body.investissement_id);
|
||||||
adjustSimulForActuals(db, body.investissement_id);
|
adjustSimulForActuals(db, body.investissement_id);
|
||||||
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
||||||
|
{
|
||||||
|
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
|
||||||
|
syncFraisDistribution(
|
||||||
|
r.lastInsertRowid, body.investissement_id, invPlat?.plateforme_id ?? null, body.frais_ttc, body.date_remb,
|
||||||
|
body.frais_mode_reglement, body.frais_mode_reglement === 'compte_courant' ? body.frais_compte_id : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
// ── Réinvestissement automatique des intérêts ──────────────────────────
|
// ── Réinvestissement automatique des intérêts ──────────────────────────
|
||||||
{
|
{
|
||||||
const inv = db.prepare(
|
const inv = db.prepare(
|
||||||
@@ -245,12 +319,12 @@ router.post('/', (req, res, next) => {
|
|||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO remboursements
|
INSERT INTO remboursements
|
||||||
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
||||||
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts,
|
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
|
||||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes)
|
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes)
|
||||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?, ?,?, ?, 'manuel', ?)
|
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?)
|
||||||
`).run(
|
`).run(
|
||||||
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
||||||
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
|
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||||
interets_nets, net_recu, body.statut, body.notes || null,
|
interets_nets, net_recu, body.statut, body.notes || null,
|
||||||
);
|
);
|
||||||
@@ -278,9 +352,13 @@ router.post('/reprocess', (req, res, next) => {
|
|||||||
|
|
||||||
const rembs = db.prepare(`
|
const rembs = db.prepare(`
|
||||||
SELECT r.id, r.investissement_id, r.capital, r.cashback, r.date_remb,
|
SELECT r.id, r.investissement_id, r.capital, r.cashback, r.date_remb,
|
||||||
r.interets_bruts_avant_local, r.taxe_locale, r.interets_bruts,
|
r.interets_bruts_avant_local, r.taxe_locale, r.interets_bruts, r.frais_ttc,
|
||||||
p.fiscalite, p.taux_fiscalite_locale,
|
p.fiscalite, p.taux_fiscalite_locale,
|
||||||
i.fiscalite_override
|
i.fiscalite_override,
|
||||||
|
-- Étape 5 bis, 01/09/26 : le frais n'est pas toujours réglé SUR ce remboursement
|
||||||
|
-- (cf. computeChamps/syncFraisDistribution) — sous-requête scalaire pour la même
|
||||||
|
-- raison qu'au GET / ci-dessus (éviter l'ambiguïté investissement_id/plateforme_id).
|
||||||
|
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement
|
||||||
FROM remboursements r
|
FROM remboursements r
|
||||||
JOIN investissements i ON i.id = r.investissement_id
|
JOIN investissements i ON i.id = r.investissement_id
|
||||||
JOIN plateformes p ON p.id = i.plateforme_id
|
JOIN plateformes p ON p.id = i.plateforme_id
|
||||||
@@ -302,6 +380,8 @@ router.post('/reprocess', (req, res, next) => {
|
|||||||
interets_nets=?, net_recu=?
|
interets_nets=?, net_recu=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`);
|
`);
|
||||||
|
// frais_ttc n'est pas recalculé ici (montant saisi manuellement, pas dérivé
|
||||||
|
// d'un taux) — seule la base imposable (interets_bruts - frais_ttc) en tient compte.
|
||||||
|
|
||||||
const stmtUpdateRetrait = db.prepare(`
|
const stmtUpdateRetrait = db.prepare(`
|
||||||
UPDATE depots_retraits SET montant=?
|
UPDATE depots_retraits SET montant=?
|
||||||
@@ -328,18 +408,25 @@ router.post('/reprocess', (req, res, next) => {
|
|||||||
interets_bruts = round2(base - taxe_locale);
|
interets_bruts = round2(base - taxe_locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Base imposable nette de frais : imposition = f(interets_bruts - frais_ttc), MAIS
|
||||||
|
// uniquement quand le frais est retenu SUR ce remboursement — cf. computeChamps()
|
||||||
|
// ci-dessus (Étape 5 bis, 01/09/26) et l'avis d'opéré Kyoseil-AM (Fundora), 30/08/26.
|
||||||
|
const fraisReduitBase = !r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement';
|
||||||
|
const fraisTtc = fraisReduitBase ? (r.frais_ttc || 0) : 0;
|
||||||
|
const baseNetteDeFrais = round2(interets_bruts - fraisTtc);
|
||||||
|
|
||||||
// Prélèvements calculés pour TOUTES les plateformes (indicatif pour les non-flat_tax)
|
// Prélèvements calculés pour TOUTES les plateformes (indicatif pour les non-flat_tax)
|
||||||
if (rates) {
|
if (rates) {
|
||||||
prelev_sociaux = round2(interets_bruts * rates.prelev_sociaux / 100);
|
prelev_sociaux = round2(baseNetteDeFrais * rates.prelev_sociaux / 100);
|
||||||
prelev_forfaitaire = round2(interets_bruts * rates.impot_revenu / 100);
|
prelev_forfaitaire = round2(baseNetteDeFrais * rates.impot_revenu / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
const interets_nets = round2(interets_bruts - prelev_sociaux - prelev_forfaitaire);
|
const interets_nets = round2(baseNetteDeFrais - prelev_sociaux - prelev_forfaitaire);
|
||||||
// Plateforme étrangère ou investissement exonéré : le montant versé n'inclut pas
|
// Plateforme étrangère ou investissement exonéré : le montant versé n'inclut pas
|
||||||
// les prélèvements indicatifs — l'investisseur reçoit le brut intégral.
|
// les prélèvements indicatifs — l'investisseur reçoit le brut (net de frais) intégral.
|
||||||
const isTaxIndicatif = r.fiscalite !== 'flat_tax' || r.fiscalite_override === 'exonere';
|
const isTaxIndicatif = r.fiscalite !== 'flat_tax' || r.fiscalite_override === 'exonere';
|
||||||
const net_recu = isTaxIndicatif
|
const net_recu = isTaxIndicatif
|
||||||
? round2((r.capital || 0) + (r.cashback || 0) + interets_bruts)
|
? round2((r.capital || 0) + (r.cashback || 0) + baseNetteDeFrais)
|
||||||
: round2((r.capital || 0) + (r.cashback || 0) + interets_nets);
|
: round2((r.capital || 0) + (r.cashback || 0) + interets_nets);
|
||||||
|
|
||||||
stmtUpdate.run(taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire,
|
stmtUpdate.run(taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire,
|
||||||
@@ -376,16 +463,17 @@ router.put('/:id', (req, res, next) => {
|
|||||||
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body));
|
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body));
|
||||||
|
|
||||||
if (body.type === 'normal') {
|
if (body.type === 'normal') {
|
||||||
|
if (body.frais_mode_reglement === 'compte_courant') assertOwnedCompte(body.frais_compte_id, req.user.id);
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE remboursements
|
UPDATE remboursements
|
||||||
SET date_remb=?, capital=?, cashback=?,
|
SET date_remb=?, capital=?, cashback=?,
|
||||||
interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?,
|
interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
|
||||||
prelev_sociaux=?, prelev_forfaitaire=?,
|
prelev_sociaux=?, prelev_forfaitaire=?,
|
||||||
interets_nets=?, net_recu=?, statut=?, notes=?, methode_remboursement=?, compte_id=?
|
interets_nets=?, net_recu=?, statut=?, notes=?, methode_remboursement=?, compte_id=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(
|
`).run(
|
||||||
body.date_remb, body.capital, body.cashback,
|
body.date_remb, body.capital, body.cashback,
|
||||||
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
|
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||||
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
|
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
|
||||||
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
|
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
|
||||||
@@ -394,14 +482,21 @@ router.put('/:id', (req, res, next) => {
|
|||||||
syncInvestissementStatut(body.investissement_id);
|
syncInvestissementStatut(body.investissement_id);
|
||||||
adjustSimulForActuals(db, body.investissement_id);
|
adjustSimulForActuals(db, body.investissement_id);
|
||||||
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
||||||
|
{
|
||||||
|
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
|
||||||
|
syncFraisDistribution(
|
||||||
|
id, body.investissement_id, invPlat?.plateforme_id ?? null, body.frais_ttc, body.date_remb,
|
||||||
|
body.frais_mode_reglement, body.frais_mode_reglement === 'compte_courant' ? body.frais_compte_id : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (body.type === 'interets_plateforme') {
|
} else if (body.type === 'interets_plateforme') {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE remboursements
|
UPDATE remboursements
|
||||||
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?,
|
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
|
||||||
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?
|
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(
|
`).run(
|
||||||
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
|
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||||
interets_nets, net_recu, body.statut, body.notes || null, id,
|
interets_nets, net_recu, body.statut, body.notes || null, id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ const round2 = v => Math.round((v ?? 0) * 100) / 100;
|
|||||||
* n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés
|
* n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés
|
||||||
* par année via `?annee=`. `gain_net_depuis_debut` est TOUJOURS un cumul
|
* par année via `?annee=`. `gain_net_depuis_debut` est TOUJOURS un cumul
|
||||||
* total (indépendant de `?annee=`) : intérêts nets + cashback/bonus de
|
* total (indépendant de `?annee=`) : intérêts nets + cashback/bonus de
|
||||||
* parrainage ou de plateforme + corrections de solde, sur tout
|
* parrainage ou de plateforme + corrections de solde − frais réglés hors
|
||||||
* l'historique du portefeuille — reste valable même après un retrait
|
* remboursement (porte-monnaie ou compte courant du détenteur, cf.
|
||||||
|
* /frais-operations — le détail est dans `gain_net_depuis_debut.frais_hors_remboursement`),
|
||||||
|
* sur tout l'historique du portefeuille — reste valable même après un retrait
|
||||||
* complet des plateformes, contrairement à une approche basée sur le
|
* complet des plateformes, contrairement à une approche basée sur le
|
||||||
* solde courant.
|
* solde courant.
|
||||||
* tags: [Dashboard]
|
* tags: [Dashboard]
|
||||||
@@ -121,8 +123,21 @@ router.get('/', (req, res) => {
|
|||||||
WHERE ${invCond('investisseur_id')}
|
WHERE ${invCond('investisseur_id')}
|
||||||
`).get(invParam);
|
`).get(invParam);
|
||||||
|
|
||||||
|
// ── Frais hors remboursement (Étape 5, 01/09/26 — cf. project_gain_net_depuis_debut.md) :
|
||||||
|
// les frais réglés en mode "portefeuille" ou "compte_courant" sont une sortie d'argent réelle
|
||||||
|
// et séparée des remboursements (contrairement au mode "remboursement", déjà déduit de
|
||||||
|
// interets_nets ci-dessus) — ils doivent donc être soustraits du gain net, en miroir exact du
|
||||||
|
// correctif appliqué au KPI "Gain net depuis le début" de l'app interne (Dashboard.jsx).
|
||||||
|
const fraisHorsRembRow = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(f.montant), 0) AS total
|
||||||
|
FROM frais_operations f
|
||||||
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
|
WHERE f.mode_reglement IN ('portefeuille','compte_courant') AND ${invCond('i.investisseur_id')}
|
||||||
|
`).get(invParam);
|
||||||
|
|
||||||
const gainNetTotal = round2(
|
const gainNetTotal = round2(
|
||||||
gainNetRow.interets_nets_total + gainNetRow.cashback_total + correctionsRow.total
|
gainNetRow.interets_nets_total + gainNetRow.cashback_total + correctionsRow.total
|
||||||
|
- fraisHorsRembRow.total
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -134,6 +149,7 @@ router.get('/', (req, res) => {
|
|||||||
interets_nets: round2(gainNetRow.interets_nets_total),
|
interets_nets: round2(gainNetRow.interets_nets_total),
|
||||||
cashback: round2(gainNetRow.cashback_total),
|
cashback: round2(gainNetRow.cashback_total),
|
||||||
corrections: round2(correctionsRow.total),
|
corrections: round2(correctionsRow.total),
|
||||||
|
frais_hors_remboursement: round2(fraisHorsRembRow.total),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import db from '../../db/index.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /frais-operations:
|
||||||
|
* get:
|
||||||
|
* summary: Liste des frais liés aux investissements (entrée, gestion, distribution)
|
||||||
|
* description: >
|
||||||
|
* Frais facturés par certaines plateformes en dehors des intérêts/prélèvements fiscaux
|
||||||
|
* habituels : frais d'entrée à la souscription, frais de gestion, frais sur distribution
|
||||||
|
* (retenus ou non sur un remboursement), ou autre. `mode_reglement` indique comment le
|
||||||
|
* frais a été réglé — "source" : aucun mouvement de trésorerie réel, montant purement
|
||||||
|
* indicatif (déjà déduit par la plateforme en amont) ; "portefeuille" : débité du
|
||||||
|
* porte-monnaie de la plateforme ; "compte_courant" : débité d'un compte courant du
|
||||||
|
* détenteur (`compte_nom`), sans impact sur le porte-monnaie ; "remboursement" : retenu
|
||||||
|
* directement sur le remboursement lié (`remboursement_id`), déjà pris en compte dans les
|
||||||
|
* champs `interets_nets`/`net_recu` de ce remboursement — ne pas le compter une seconde
|
||||||
|
* fois. Seuls les modes "portefeuille" et "compte_courant" représentent une sortie
|
||||||
|
* d'argent réelle et séparée (c'est ce sous-ensemble qui est déduit de
|
||||||
|
* `gain_net_depuis_debut` sur /dashboard).
|
||||||
|
* tags: [Frais]
|
||||||
|
* security: [{ ApiKeyAuth: [] }]
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: date_debut
|
||||||
|
* schema: { type: string, format: date }
|
||||||
|
* - in: query
|
||||||
|
* name: date_fin
|
||||||
|
* schema: { type: string, format: date }
|
||||||
|
* responses:
|
||||||
|
* 200: { description: Liste des frais }
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const { date_debut, date_fin } = req.query;
|
||||||
|
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
|
||||||
|
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
|
||||||
|
const conds = [req.investisseurScopeAll
|
||||||
|
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||||
|
: 'i.investisseur_id = ?'];
|
||||||
|
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
|
||||||
|
if (date_debut) { conds.push('f.date_operation >= ?'); args.push(date_debut); }
|
||||||
|
if (date_fin) { conds.push('f.date_operation <= ?'); args.push(date_fin); }
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT f.id, i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
|
||||||
|
f.montant, f.mode_reglement, f.categorie, f.date_operation,
|
||||||
|
f.remboursement_id, c.nom AS compte_nom, f.notes
|
||||||
|
FROM frais_operations f
|
||||||
|
JOIN investissements i ON i.id = f.investissement_id
|
||||||
|
JOIN plateformes p ON p.id = i.plateforme_id
|
||||||
|
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||||
|
WHERE ${conds.join(' AND ')}
|
||||||
|
ORDER BY f.date_operation DESC, f.id DESC
|
||||||
|
`).all(...args);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -3,6 +3,7 @@ import investisseurRouter from './investisseur.js';
|
|||||||
import investissementsRouter from './investissements.js';
|
import investissementsRouter from './investissements.js';
|
||||||
import remboursementsRouter from './remboursements.js';
|
import remboursementsRouter from './remboursements.js';
|
||||||
import depotsRetraitsRouter from './depotsRetraits.js';
|
import depotsRetraitsRouter from './depotsRetraits.js';
|
||||||
|
import fraisOperationsRouter from './fraisOperations.js';
|
||||||
import dashboardRouter from './dashboard.js';
|
import dashboardRouter from './dashboard.js';
|
||||||
import objectifsRouter from './objectifs.js';
|
import objectifsRouter from './objectifs.js';
|
||||||
import taxreportRouter from './taxreport.js';
|
import taxreportRouter from './taxreport.js';
|
||||||
@@ -13,6 +14,7 @@ router.use('/investisseur', investisseurRouter);
|
|||||||
router.use('/investissements', investissementsRouter);
|
router.use('/investissements', investissementsRouter);
|
||||||
router.use('/remboursements', remboursementsRouter);
|
router.use('/remboursements', remboursementsRouter);
|
||||||
router.use('/depots-retraits', depotsRetraitsRouter);
|
router.use('/depots-retraits', depotsRetraitsRouter);
|
||||||
|
router.use('/frais-operations', fraisOperationsRouter);
|
||||||
router.use('/dashboard', dashboardRouter);
|
router.use('/dashboard', dashboardRouter);
|
||||||
router.use('/objectifs', objectifsRouter);
|
router.use('/objectifs', objectifsRouter);
|
||||||
router.use('/taxreport', taxreportRouter);
|
router.use('/taxreport', taxreportRouter);
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ router.get('/', (req, res) => {
|
|||||||
* /investissements/{id}:
|
* /investissements/{id}:
|
||||||
* get:
|
* get:
|
||||||
* summary: Détail d'un investissement, avec ses remboursements réels
|
* summary: Détail d'un investissement, avec ses remboursements réels
|
||||||
|
* description: >
|
||||||
|
* `remboursements[].frais_ttc` est le montant de frais saisi sur ce remboursement ; il ne
|
||||||
|
* réduit `interets_nets`/`net_recu` que si `frais_mode_reglement` vaut "remboursement".
|
||||||
|
* `frais_operations` liste tous les frais rattachés à cet investissement (souscription,
|
||||||
|
* gestion, distribution, autre), quel que soit leur mode de règlement — à la source
|
||||||
|
* (aucun mouvement réel, montant indicatif), porte-monnaie, compte courant, ou retenu sur
|
||||||
|
* un remboursement (auto-synchronisé, `remboursement_id` renseigné). Seuls les frais en
|
||||||
|
* mode "portefeuille" ou "compte_courant" représentent une sortie d'argent réelle et
|
||||||
|
* distincte des remboursements.
|
||||||
* tags: [Investissements]
|
* tags: [Investissements]
|
||||||
* security: [{ ApiKeyAuth: [] }]
|
* security: [{ ApiKeyAuth: [] }]
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -77,14 +86,29 @@ router.get('/:id', (req, res, next) => {
|
|||||||
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
||||||
|
|
||||||
const remboursements = db.prepare(`
|
const remboursements = db.prepare(`
|
||||||
SELECT id, date_remb, capital, interets_bruts, prelev_sociaux,
|
SELECT r.id, r.date_remb, r.capital, r.interets_bruts, r.frais_ttc,
|
||||||
prelev_forfaitaire, cashback, interets_nets, net_recu, statut
|
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement,
|
||||||
FROM remboursements
|
r.prelev_sociaux, r.prelev_forfaitaire, r.cashback, r.interets_nets, r.net_recu, r.statut
|
||||||
WHERE investissement_id = ?
|
FROM remboursements r
|
||||||
ORDER BY date_remb
|
WHERE r.investissement_id = ?
|
||||||
|
ORDER BY r.date_remb
|
||||||
`).all(inv.id);
|
`).all(inv.id);
|
||||||
|
|
||||||
res.json({ ...inv, remboursements });
|
// Frais liés à cet investissement (souscription, gestion, distribution, autre — tous modes
|
||||||
|
// de règlement confondus). Les lignes de catégorie 'distribution' liées à un remboursement
|
||||||
|
// (remboursement_id non nul) sont synchronisées avec le champ frais_ttc de ce remboursement
|
||||||
|
// ci-dessus — ne pas les additionner aux deux si frais_mode_reglement = 'remboursement'
|
||||||
|
// (le montant est alors déjà déduit de interets_nets/net_recu, cf. /remboursements).
|
||||||
|
const fraisOperations = db.prepare(`
|
||||||
|
SELECT f.id, f.montant, f.mode_reglement, f.categorie, f.date_operation,
|
||||||
|
f.remboursement_id, c.nom AS compte_nom, f.notes
|
||||||
|
FROM frais_operations f
|
||||||
|
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||||
|
WHERE f.investissement_id = ?
|
||||||
|
ORDER BY f.date_operation DESC, f.id DESC
|
||||||
|
`).all(inv.id);
|
||||||
|
|
||||||
|
res.json({ ...inv, remboursements, frais_operations: fraisOperations });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const router = Router();
|
|||||||
* /remboursements:
|
* /remboursements:
|
||||||
* get:
|
* get:
|
||||||
* summary: Liste des remboursements réels perçus
|
* summary: Liste des remboursements réels perçus
|
||||||
|
* description: >
|
||||||
|
* `frais_ttc` est toujours le montant de frais saisi sur ce remboursement, mais il ne
|
||||||
|
* réduit `interets_nets`/`net_recu` que lorsque `frais_mode_reglement` vaut
|
||||||
|
* "remboursement" (retenu sur ce versement). Avec "portefeuille" ou "compte_courant",
|
||||||
|
* le frais est réglé à part (cf. /frais-operations) et n'a aucun impact sur ce
|
||||||
|
* remboursement — ne pas le soustraire une seconde fois.
|
||||||
* tags: [Remboursements]
|
* tags: [Remboursements]
|
||||||
* security: [{ ApiKeyAuth: [] }]
|
* security: [{ ApiKeyAuth: [] }]
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -33,7 +39,9 @@ router.get('/', (req, res) => {
|
|||||||
|
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
|
SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
|
||||||
r.capital, r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
|
r.capital, r.interets_bruts, r.frais_ttc,
|
||||||
|
(SELECT mode_reglement FROM frais_operations WHERE remboursement_id = r.id) AS frais_mode_reglement,
|
||||||
|
r.prelev_sociaux, r.prelev_forfaitaire,
|
||||||
r.cashback, r.interets_nets, r.net_recu, r.statut
|
r.cashback, r.interets_nets, r.net_recu, r.statut
|
||||||
FROM remboursements r
|
FROM remboursements r
|
||||||
JOIN investissements i ON i.id = r.investissement_id
|
JOIN investissements i ON i.id = r.investissement_id
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import notationRouter from './routes/notation.js';
|
|||||||
import garantiesRouter from './routes/garanties.js';
|
import garantiesRouter from './routes/garanties.js';
|
||||||
import objectifsRouter from './routes/objectifs.js';
|
import objectifsRouter from './routes/objectifs.js';
|
||||||
import reinvestissementsRouter from './routes/reinvestissements.js';
|
import reinvestissementsRouter from './routes/reinvestissements.js';
|
||||||
|
import fraisOperationsRouter from './routes/fraisOperations.js';
|
||||||
import correctionsRouter from './routes/corrections.js';
|
import correctionsRouter from './routes/corrections.js';
|
||||||
import comptesRouter from './routes/comptes.js';
|
import comptesRouter from './routes/comptes.js';
|
||||||
import preferencesRouter from './routes/preferences.js';
|
import preferencesRouter from './routes/preferences.js';
|
||||||
@@ -136,6 +137,7 @@ app.use('/api/notation', requireAuth, notationRouter);
|
|||||||
app.use('/api/garanties', requireAuth, garantiesRouter);
|
app.use('/api/garanties', requireAuth, garantiesRouter);
|
||||||
app.use('/api/objectifs', requireAuth, objectifsRouter);
|
app.use('/api/objectifs', requireAuth, objectifsRouter);
|
||||||
app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter);
|
app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter);
|
||||||
|
app.use('/api/frais-operations', requireAuth, fraisOperationsRouter);
|
||||||
app.use('/api/corrections', requireAuth, correctionsRouter);
|
app.use('/api/corrections', requireAuth, correctionsRouter);
|
||||||
app.use('/api/comptes', requireAuth, comptesRouter);
|
app.use('/api/comptes', requireAuth, comptesRouter);
|
||||||
app.use('/api/preferences', requireAuth, preferencesRouter);
|
app.use('/api/preferences', requireAuth, preferencesRouter);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
@@ -0,0 +1,98 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M4894 8872 c-31 -25 -40 -47 -54 -142 -20 -135 -18 -131 -96 -161
|
||||||
|
-38 -14 -100 -40 -139 -58 -38 -17 -75 -29 -82 -27 -7 3 -49 33 -93 67 -56 42
|
||||||
|
-92 63 -118 66 -35 5 -44 0 -98 -45 -32 -27 -101 -92 -151 -145 -82 -85 -93
|
||||||
|
-100 -93 -132 0 -43 4 -52 75 -151 30 -42 55 -85 55 -95 0 -11 -11 -41 -24
|
||||||
|
-67 -13 -26 -34 -78 -46 -117 -13 -38 -28 -76 -35 -83 -10 -11 -63 -23 -178
|
||||||
|
-37 -33 -4 -58 -15 -80 -35 l-32 -28 0 -207 0 -207 35 -32 c31 -28 47 -34 125
|
||||||
|
-45 128 -18 129 -19 160 -103 15 -40 41 -101 58 -135 l30 -62 -36 -53 c-20
|
||||||
|
-29 -53 -75 -72 -102 -53 -73 -49 -102 23 -179 264 -282 262 -281 418 -151 71
|
||||||
|
60 87 62 167 21 36 -20 96 -45 132 -57 36 -12 69 -29 74 -38 4 -10 14 -55 21
|
||||||
|
-102 26 -183 21 -180 277 -180 253 0 264 7 284 170 6 52 15 103 20 112 5 10
|
||||||
|
28 22 51 28 23 7 83 32 135 57 109 53 102 54 213 -31 78 -61 105 -72 147 -61
|
||||||
|
39 9 299 276 308 314 8 37 -1 54 -81 159 -35 45 -64 87 -64 92 0 5 14 36 30
|
||||||
|
69 17 33 42 90 55 127 13 37 29 72 34 77 6 6 53 16 105 23 53 7 108 20 124 28
|
||||||
|
56 29 57 32 57 257 0 206 0 208 -24 233 -27 29 -65 41 -161 51 -100 11 -105
|
||||||
|
15 -130 92 -13 37 -38 99 -57 136 -39 79 -43 65 53 189 61 79 71 106 55 146
|
||||||
|
-7 16 -72 88 -145 161 -116 116 -138 133 -172 138 -33 4 -46 0 -89 -31 -27
|
||||||
|
-19 -73 -52 -102 -72 l-52 -36 -93 45 c-51 25 -112 51 -134 56 -23 6 -47 18
|
||||||
|
-54 26 -7 8 -18 54 -25 102 -19 124 -24 141 -52 163 -24 19 -41 20 -232 20
|
||||||
|
-180 0 -209 -3 -227 -18z m376 -586 c88 -16 217 -64 280 -104 120 -76 237
|
||||||
|
-204 305 -332 62 -119 85 -221 85 -378 0 -163 -15 -231 -80 -367 -192 -399
|
||||||
|
-653 -572 -1059 -396 -224 96 -392 276 -460 491 -63 199 -65 342 -7 532 48
|
||||||
|
157 170 317 317 419 71 49 85 56 179 95 124 51 293 66 440 40z"/>
|
||||||
|
<path d="M5430 8041 l-65 -6 0 -430 0 -430 88 -3 c57 -2 94 1 103 9 12 9 14
|
||||||
|
84 14 434 0 459 3 438 -53 433 -12 -1 -51 -4 -87 -7z"/>
|
||||||
|
<path d="M5022 7828 c-19 -19 -17 -631 2 -647 9 -8 46 -11 103 -9 l88 3 7 60
|
||||||
|
c3 33 6 182 5 330 l-2 270 -95 3 c-66 2 -99 -1 -108 -10z"/>
|
||||||
|
<path d="M4672 7568 c-16 -16 -17 -337 0 -373 11 -24 15 -25 93 -25 59 0 86 4
|
||||||
|
94 14 24 29 40 363 19 384 -8 8 -46 12 -103 12 -57 0 -95 -4 -103 -12z"/>
|
||||||
|
<path d="M4582 7088 c-7 -7 -12 -26 -12 -43 0 -17 5 -36 12 -43 17 -17 1059
|
||||||
|
-17 1076 0 18 18 15 85 -4 92 -9 3 -251 6 -538 6 -393 0 -525 -3 -534 -12z"/>
|
||||||
|
<path d="M3420 6260 c-221 -20 -395 -93 -481 -201 -45 -57 -70 -132 -76 -224
|
||||||
|
-4 -73 -11 -100 -64 -232 -23 -60 -59 -1 -59 97 0 143 -46 242 -148 322 -101
|
||||||
|
77 -267 128 -435 131 l-102 2 -3 -41 c-8 -109 76 -329 158 -412 102 -104 170
|
||||||
|
-133 323 -140 l117 -4 20 -45 c31 -71 61 -233 43 -233 -4 0 -46 16 -93 37
|
||||||
|
-111 47 -210 57 -252 23 -16 -12 -28 -31 -28 -43 0 -21 151 -254 174 -269 6
|
||||||
|
-4 133 -8 283 -8 268 0 272 0 297 23 34 30 166 235 166 259 0 37 -36 58 -97
|
||||||
|
58 -61 0 -102 -11 -217 -54 -38 -14 -72 -24 -74 -21 -17 17 34 284 69 359 13
|
||||||
|
28 15 28 74 22 195 -19 372 84 460 270 62 130 95 314 59 328 -9 3 -19 5 -22 5
|
||||||
|
-4 -1 -45 -5 -92 -9z"/>
|
||||||
|
<path d="M7523 6005 c-228 -71 -328 -348 -192 -534 32 -46 40 -47 172 -34 46
|
||||||
|
4 67 10 67 19 0 8 -11 16 -24 20 -13 3 -34 16 -45 28 -19 20 -20 26 -9 50 10
|
||||||
|
24 16 27 42 21 17 -4 54 -8 81 -8 44 -2 50 0 50 18 0 15 -14 25 -60 41 -84 29
|
||||||
|
-125 69 -125 121 0 42 31 86 71 98 11 4 26 20 34 36 20 37 60 40 76 5 6 -13
|
||||||
|
29 -31 51 -39 36 -15 39 -19 36 -49 -3 -36 -4 -37 -100 -22 -39 5 -49 4 -54
|
||||||
|
-10 -9 -22 11 -43 49 -52 41 -8 83 -30 108 -55 14 -14 19 -33 19 -70 0 -53
|
||||||
|
-18 -81 -79 -120 -44 -28 -17 -39 95 -39 124 0 137 8 174 106 31 84 26 207
|
||||||
|
-13 287 -36 72 -119 149 -192 176 -65 24 -167 27 -232 6z"/>
|
||||||
|
<path d="M7005 5810 c-155 -32 -270 -160 -283 -315 l-5 -65 247 0 c135 0 246
|
||||||
|
3 246 7 0 3 -11 35 -25 69 -31 77 -43 195 -25 248 7 21 8 41 4 46 -14 12 -115
|
||||||
|
19 -159 10z"/>
|
||||||
|
<path d="M6440 5613 c-19 -8 -52 -31 -73 -51 -77 -74 -72 -19 -75 -776 -3
|
||||||
|
-770 -4 -754 69 -821 82 -74 29 -70 864 -73 413 -2 773 0 802 3 64 8 110 38
|
||||||
|
140 90 20 35 23 54 23 162 l0 121 -198 4 -198 3 -66 33 c-81 40 -139 102 -169
|
||||||
|
181 -17 45 -20 72 -17 137 4 69 9 90 39 145 41 73 101 125 182 154 44 16 80
|
||||||
|
19 237 20 l185 0 2 125 c3 114 1 128 -20 165 -46 80 -48 81 -379 89 -161 3
|
||||||
|
-518 4 -794 0 l-501 -6 -30 26 c-37 31 -48 67 -33 110 13 41 40 55 110 58 59
|
||||||
|
3 68 13 90 89 l9 29 -82 -1 c-49 0 -96 -7 -117 -16z"/>
|
||||||
|
<path d="M2438 4873 c-131 -119 -234 -252 -299 -389 -51 -106 -69 -181 -69
|
||||||
|
-280 0 -148 68 -239 214 -287 55 -18 95 -20 401 -24 187 -2 402 0 478 6 120 8
|
||||||
|
146 13 200 39 114 54 158 120 165 250 7 131 -29 243 -132 409 -54 87 -167 221
|
||||||
|
-237 282 l-37 31 -321 0 -321 0 -42 -37z"/>
|
||||||
|
<path d="M7775 4813 c-44 -23 -74 -52 -102 -101 -22 -37 -25 -51 -21 -115 5
|
||||||
|
-89 35 -143 105 -184 48 -28 48 -28 253 -30 235 -2 272 4 300 50 17 28 20 51
|
||||||
|
20 174 0 156 -8 186 -51 209 -43 22 -462 20 -504 -3z m168 -132 c30 -29 34
|
||||||
|
-100 8 -133 -28 -36 -82 -46 -126 -25 -68 32 -68 143 -1 173 39 18 94 11 119
|
||||||
|
-15z"/>
|
||||||
|
<path d="M4006 3709 c-44 -5 -105 -14 -135 -20 -117 -24 -323 -99 -443 -162
|
||||||
|
-158 -83 -605 -307 -613 -307 -3 0 -44 -17 -91 -37 -54 -23 -84 -42 -84 -52 0
|
||||||
|
-9 31 -92 69 -186 116 -284 400 -1007 421 -1073 7 -23 19 -47 25 -52 8 -7 58
|
||||||
|
12 161 61 198 95 228 106 319 118 139 20 207 5 815 -182 1002 -308 1026 -314
|
||||||
|
1156 -304 177 13 217 30 836 339 605 303 736 376 1063 593 315 209 812 550
|
||||||
|
868 596 52 42 78 98 79 167 2 166 -170 276 -376 241 -90 -15 -91 -15 -551
|
||||||
|
-245 -214 -107 -462 -225 -550 -262 -123 -53 -163 -74 -172 -93 -6 -13 -30
|
||||||
|
-64 -54 -114 -69 -146 -173 -229 -339 -271 -92 -23 -223 -19 -1016 37 -217 15
|
||||||
|
-269 10 -380 -36 -170 -71 -168 -70 -201 -56 -33 14 -63 58 -63 94 0 13 10 37
|
||||||
|
22 52 29 37 168 101 277 127 78 19 104 20 286 14 193 -6 443 -23 705 -47 173
|
||||||
|
-15 322 -8 382 19 194 87 249 317 110 460 -69 70 -120 90 -290 112 -81 11
|
||||||
|
-178 24 -217 30 -38 6 -167 22 -285 36 -330 39 -500 83 -720 185 -409 188
|
||||||
|
-700 251 -1014 218z"/>
|
||||||
|
<path d="M1988 3201 c-130 -53 -251 -106 -267 -119 -42 -31 -57 -85 -38 -137
|
||||||
|
50 -136 70 -188 96 -250 16 -38 57 -140 91 -225 34 -85 69 -173 79 -195 10
|
||||||
|
-22 24 -58 31 -80 7 -22 23 -65 36 -95 13 -30 71 -179 130 -330 58 -151 112
|
||||||
|
-288 119 -305 7 -16 27 -68 45 -115 36 -97 59 -126 111 -140 44 -12 16 -21
|
||||||
|
325 102 155 61 260 108 274 123 26 28 39 79 29 120 -6 25 -95 247 -128 320 -5
|
||||||
|
11 -15 36 -21 55 -7 19 -20 51 -28 70 -22 53 -26 64 -87 220 -31 80 -69 177
|
||||||
|
-85 215 -15 39 -46 117 -69 175 -22 58 -81 211 -132 340 -50 129 -96 248 -102
|
||||||
|
263 -5 16 -23 42 -39 58 -51 51 -95 43 -370 -70z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -462,7 +462,33 @@ export default function DrillCellPanel({
|
|||||||
if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts;
|
if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts;
|
||||||
else if (c.key === 'capital') v = r.capital ?? 0;
|
else if (c.key === 'capital') v = r.capital ?? 0;
|
||||||
else v = r.cashback ?? 0;
|
else v = r.cashback ?? 0;
|
||||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(v)}</td>;
|
// Icône uniquement si le frais réduit réellement ce montant (mode
|
||||||
|
// 'remboursement', par défaut) — cf. RembFormModal.jsx, Étape 5 bis (01/09/26).
|
||||||
|
const showFraisIcon = c.key === 'interets' && !netMode && !!r.frais_ttc && (!r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement');
|
||||||
|
return (
|
||||||
|
<td key={c.key} style={{ ...tdStyle, ...numStyle }}>
|
||||||
|
{fmtEUR(v)}
|
||||||
|
{showFraisIcon && (
|
||||||
|
<span
|
||||||
|
className="cell-tooltip"
|
||||||
|
data-tooltip={
|
||||||
|
`Intérêts bruts avant frais : ${fmtEUR(r.interets_bruts)}\n` +
|
||||||
|
`Frais de distribution : -${fmtEUR(r.frais_ttc)}\n` +
|
||||||
|
`Base après frais (avant prélèvements) : ${fmtEUR(round2(r.interets_bruts - r.frais_ttc))}\n` +
|
||||||
|
`Prélèv. sociaux : ${fmtEUR(r.prelev_sociaux)}\n` +
|
||||||
|
`Impôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}\n` +
|
||||||
|
`Montant net reçu : ${fmtEUR(r.net_recu)}`
|
||||||
|
}
|
||||||
|
style={{ marginLeft: 5, color: 'var(--warning, #b45309)', verticalAlign: 'middle' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(recuRowValue(r))}</td>
|
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(recuRowValue(r))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useMemo, useState, useRef, useCallback } from 'react';
|
||||||
|
import { FRAIS_CATEGORIE_LABELS } from './MouvementsShared.jsx';
|
||||||
|
|
||||||
|
/* ── Palette ────────────────────────────────────────────────── */
|
||||||
|
const PALETTE = [
|
||||||
|
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
|
||||||
|
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
|
||||||
|
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ── Algorithme treemap (binary split équilibré) ─────────────── */
|
||||||
|
function buildTreemap(items, x, y, w, h) {
|
||||||
|
if (!items.length) return [];
|
||||||
|
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
|
||||||
|
|
||||||
|
const total = items.reduce((s, i) => s + i.value, 0);
|
||||||
|
let best = 1, bestDiff = Infinity, acc = 0;
|
||||||
|
for (let i = 0; i < items.length - 1; i++) {
|
||||||
|
acc += items[i].value;
|
||||||
|
const diff = Math.abs(acc - (total - acc));
|
||||||
|
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const g1 = items.slice(0, best);
|
||||||
|
const g2 = items.slice(best);
|
||||||
|
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
|
||||||
|
|
||||||
|
if (w >= h) {
|
||||||
|
const w1 = w * r1;
|
||||||
|
return [...buildTreemap(g1, x, y, w1, h), ...buildTreemap(g2, x + w1, y, w - w1, h)];
|
||||||
|
} else {
|
||||||
|
const h1 = h * r1;
|
||||||
|
return [...buildTreemap(g1, x, y, w, h1), ...buildTreemap(g2, x, y + h1, w, h - h1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAmount(v) {
|
||||||
|
return Math.round(v).toLocaleString('fr-FR') + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapText(text, maxWidth, fontSize) {
|
||||||
|
const charW = fontSize * 0.58;
|
||||||
|
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
|
||||||
|
const words = text.split(' ');
|
||||||
|
const lines = [];
|
||||||
|
let current = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const candidate = current ? current + ' ' + word : word;
|
||||||
|
if (candidate.length <= maxChars) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
if (current) lines.push(current);
|
||||||
|
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) lines.push(current);
|
||||||
|
return lines.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Composant ──────────────────────────────────────────────── */
|
||||||
|
export default function FraisDistributionChart({ rows }) {
|
||||||
|
const [hoveredIdx, setHoveredIdx] = useState(null);
|
||||||
|
const [tooltip, setTooltip] = useState(null);
|
||||||
|
const svgRef = useRef(null);
|
||||||
|
const wrapRef = useRef(null);
|
||||||
|
|
||||||
|
/* Frais cumulés par catégorie */
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (!rows?.length) return [];
|
||||||
|
const byCat = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = FRAIS_CATEGORIE_LABELS[r.categorie] || r.categorie || 'Autre';
|
||||||
|
const val = r.montant || 0;
|
||||||
|
byCat[key] = (byCat[key] || 0) + val;
|
||||||
|
}
|
||||||
|
return Object.entries(byCat)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const total = data.reduce((s, i) => s + i.value, 0);
|
||||||
|
const W = 440, H = 290, GAP = 3;
|
||||||
|
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e, cell, idx) => {
|
||||||
|
if (!wrapRef.current) return;
|
||||||
|
const wrapRect = wrapRef.current.getBoundingClientRect();
|
||||||
|
setHoveredIdx(idx);
|
||||||
|
setTooltip({ x: e.clientX - wrapRect.left, y: e.clientY - wrapRect.top, cell });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setHoveredIdx(null);
|
||||||
|
setTooltip(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!cells.length) return null;
|
||||||
|
|
||||||
|
const TIP_W = 160, TIP_H = 66;
|
||||||
|
const tipStyle = tooltip ? (() => {
|
||||||
|
const ww = wrapRef.current?.offsetWidth || 440;
|
||||||
|
const wh = wrapRef.current?.offsetHeight || 360;
|
||||||
|
let tx = tooltip.x + 14;
|
||||||
|
let ty = tooltip.y - TIP_H / 2;
|
||||||
|
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
|
||||||
|
if (ty < 4) ty = 4;
|
||||||
|
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
|
||||||
|
return { left: tx, top: ty };
|
||||||
|
})() : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dist-chart-wrap" ref={wrapRef}>
|
||||||
|
|
||||||
|
{/* ── En-tête ── */}
|
||||||
|
<div className="dist-chart-header">
|
||||||
|
<span className="dist-chart-title">Frais</span>
|
||||||
|
<div className="dist-dropdown">
|
||||||
|
par Catégorie
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 9l6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Treemap SVG ── */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fdt-shadow" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#000" stopOpacity="0" />
|
||||||
|
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="fdt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
|
||||||
|
</filter>
|
||||||
|
{cells.map((cell, i) => {
|
||||||
|
const PAD = 4;
|
||||||
|
const gx = cell.x + GAP / 2 + PAD;
|
||||||
|
const gy = cell.y + GAP / 2 + PAD;
|
||||||
|
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
|
||||||
|
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
|
||||||
|
return (
|
||||||
|
<clipPath key={i} id={`fdt-clip-${i}`}>
|
||||||
|
<rect x={gx} y={gy} width={gw} height={gh} />
|
||||||
|
</clipPath>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{cells.map((cell, i) => {
|
||||||
|
const gx = cell.x + GAP / 2;
|
||||||
|
const gy = cell.y + GAP / 2;
|
||||||
|
const gw = Math.max(cell.w - GAP, 0);
|
||||||
|
const gh = Math.max(cell.h - GAP, 0);
|
||||||
|
const cx = gx + gw / 2;
|
||||||
|
const cy = gy + gh / 2;
|
||||||
|
const pct = ((cell.value / total) * 100).toFixed(0);
|
||||||
|
const amt = fmtAmount(cell.value);
|
||||||
|
const isHovered = hoveredIdx === i;
|
||||||
|
|
||||||
|
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
|
||||||
|
const fsName = Math.min(12, Math.max(8, gw / 9.5));
|
||||||
|
const fsPct = Math.min(11, Math.max(7, gw / 11));
|
||||||
|
const lineH = 15;
|
||||||
|
|
||||||
|
const canShowAmt = gw > 36 && gh > 20;
|
||||||
|
const isLandscape = gw > gh;
|
||||||
|
const combinedText = `${cell.name} — ${pct} %`;
|
||||||
|
const combinedCharW = fsName * 0.58;
|
||||||
|
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
|
||||||
|
const nameLines = (gw > 40 && gh > 30) ? wrapText(cell.name, gw - 10, fsName) : [];
|
||||||
|
const canShowName = nameLines.length > 0;
|
||||||
|
const CLIP_PAD = 4;
|
||||||
|
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
|
||||||
|
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
|
||||||
|
|
||||||
|
const textItems = [];
|
||||||
|
{
|
||||||
|
const slots = [];
|
||||||
|
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
|
||||||
|
if (combinedFits) {
|
||||||
|
slots.push({ type: 'combined', text: combinedText, h: lineH });
|
||||||
|
} else {
|
||||||
|
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
|
||||||
|
if (canShowPct) slots.push({ type: 'pct', h: lineH });
|
||||||
|
}
|
||||||
|
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
|
||||||
|
let y = cy - totalH / 2 + lineH / 2;
|
||||||
|
for (const sl of slots) {
|
||||||
|
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
|
||||||
|
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||||
|
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||||
|
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
|
||||||
|
y += sl.h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={i} style={{ cursor: 'pointer' }}
|
||||||
|
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
|
||||||
|
>
|
||||||
|
<rect x={gx} y={gy} width={gw} height={gh}
|
||||||
|
rx="5" fill={cell.color}
|
||||||
|
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
|
||||||
|
style={{ transition: 'opacity .15s' }}
|
||||||
|
/>
|
||||||
|
<rect x={gx} y={gy} width={gw} height={gh}
|
||||||
|
rx="5" fill="url(#fdt-shadow)" opacity="0.35" />
|
||||||
|
{isHovered && (
|
||||||
|
<rect x={gx} y={gy} width={gw} height={gh}
|
||||||
|
rx="5" fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
|
||||||
|
)}
|
||||||
|
<g clipPath={`url(#fdt-clip-${i})`}>
|
||||||
|
{textItems.map(l => (
|
||||||
|
<text key={l.key} x={cx} y={l.y}
|
||||||
|
textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="white"
|
||||||
|
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
|
||||||
|
fontSize={l.fs} fontWeight={l.fw}
|
||||||
|
fontFamily="system-ui,-apple-system,sans-serif"
|
||||||
|
filter="url(#fdt-txt-shadow)"
|
||||||
|
style={{ transition: 'fill-opacity .15s' }}>
|
||||||
|
{l.text}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* ── Tooltip ── */}
|
||||||
|
{tooltip && tipStyle && (
|
||||||
|
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
|
||||||
|
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
|
||||||
|
<div>
|
||||||
|
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
|
||||||
|
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
|
||||||
|
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,19 +87,41 @@ export default function InvestissementFormModal({
|
|||||||
if (open) setStep(1);
|
if (open) setStep(1);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const stepTitles = [
|
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
||||||
"Informations générales de l'investissement",
|
const platFrais = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||||
'Données financières et échéancier',
|
// Étape "Frais" : uniquement à la création (une fois la souscription actée, le frais initial
|
||||||
'Catégories, secteurs, autres',
|
// éventuel a déjà été journalisé ; toute correction ou tout frais complémentaire ultérieur se
|
||||||
];
|
// gère désormais depuis le bloc "Frais d'opération" de la fiche investissement — cf.
|
||||||
|
// project_frais_operation.md, revu le 31/08/26), et seulement si la plateforme facture des
|
||||||
|
// frais (référentiel frais_plateforme ≠ 'aucun').
|
||||||
|
// Visible en création ET en modification (31/08/26, demande Olivier) : en modification,
|
||||||
|
// cette étape lit/écrit toujours LA MÊME entrée frais_operations de catégorie 'souscription'
|
||||||
|
// pour cet investissement — pré-remplie par le parent (openEdit) si elle existe déjà, gérée
|
||||||
|
// en upsert par le parent au submit (POST si absente, PUT si déjà chargée via form.frais_id,
|
||||||
|
// DELETE si le montant est vidé). Le montant investi n'est PAS réajusté automatiquement en
|
||||||
|
// modification, contrairement à la création — cf. project_frais_operation.md.
|
||||||
|
const showFraisStep = !!platFrais && platFrais !== 'aucun';
|
||||||
|
const totalSteps = showFraisStep ? 4 : 3;
|
||||||
|
|
||||||
|
const stepTitles = showFraisStep
|
||||||
|
? [
|
||||||
|
"Informations générales de l'investissement",
|
||||||
|
'Données financières et échéancier',
|
||||||
|
'Frais liés à la plateforme',
|
||||||
|
'Catégories, secteurs, autres',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"Informations générales de l'investissement",
|
||||||
|
'Données financières et échéancier',
|
||||||
|
'Catégories, secteurs, autres',
|
||||||
|
];
|
||||||
|
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
if (formRef.current && !formRef.current.reportValidity()) return;
|
if (formRef.current && !formRef.current.reportValidity()) return;
|
||||||
setStep(s => Math.min(3, s + 1));
|
setStep(s => Math.min(totalSteps, s + 1));
|
||||||
};
|
};
|
||||||
const goPrev = () => setStep(s => Math.max(1, s - 1));
|
const goPrev = () => setStep(s => Math.max(1, s - 1));
|
||||||
|
|
||||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
|
||||||
const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe')
|
const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe')
|
||||||
&& dayOfMonth(form.date_premiere_echeance) > 27;
|
&& dayOfMonth(form.date_premiere_echeance) > 27;
|
||||||
|
|
||||||
@@ -155,6 +177,10 @@ export default function InvestissementFormModal({
|
|||||||
onFormPatch({ compte_id: compteId, nom_compte_courant: c?.nom || '' });
|
onFormPatch({ compte_id: compteId, nom_compte_courant: c?.nom || '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFraisReglementChange = (mode) => {
|
||||||
|
onFormPatch({ frais_mode_reglement: mode, frais_compte_id: mode === 'compte_courant' ? form.frais_compte_id : '' });
|
||||||
|
};
|
||||||
|
|
||||||
const handleTypeRembChange = (t) => {
|
const handleTypeRembChange = (t) => {
|
||||||
const next = {
|
const next = {
|
||||||
...form, type_remb: t,
|
...form, type_remb: t,
|
||||||
@@ -280,8 +306,8 @@ export default function InvestissementFormModal({
|
|||||||
</div>
|
</div>
|
||||||
{step === 1 && <button onClick={onClose}>Annuler</button>}
|
{step === 1 && <button onClick={onClose}>Annuler</button>}
|
||||||
{step > 1 && <button onClick={goPrev}>Précédent</button>}
|
{step > 1 && <button onClick={goPrev}>Précédent</button>}
|
||||||
{step < 3 && <button className="primary" onClick={goNext}>Suivant</button>}
|
{step < totalSteps && <button className="primary" onClick={goNext}>Suivant</button>}
|
||||||
{step === 3 && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
|
{step === totalSteps && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -291,7 +317,7 @@ export default function InvestissementFormModal({
|
|||||||
Étape {step} : {stepTitles[step - 1]}
|
Étape {step} : {stepTitles[step - 1]}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
{[1, 2, 3].map(n => (
|
{Array.from({ length: totalSteps }, (_, i) => i + 1).map(n => (
|
||||||
<div key={n} style={{
|
<div key={n} style={{
|
||||||
flex: 1, height: 5, borderRadius: 4,
|
flex: 1, height: 5, borderRadius: 4,
|
||||||
background: n <= step ? 'var(--primary)' : 'var(--border)',
|
background: n <= step ? 'var(--primary)' : 'var(--border)',
|
||||||
@@ -502,8 +528,68 @@ export default function InvestissementFormModal({
|
|||||||
{restructurationBlock}
|
{restructurationBlock}
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{/* ══ Étape 3 : Catégories, secteurs, autres ══ */}
|
{/* ══ Étape 3 (si frais) : Frais liés à la plateforme ══ */}
|
||||||
{step === 3 && (<>
|
{step === 3 && showFraisStep && (<>
|
||||||
|
<div style={{ gridColumn: 'span 3' }}>
|
||||||
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '0 0 4px' }}>
|
||||||
|
{editingId
|
||||||
|
? "Cette plateforme facture des frais (entrée, gestion…). Le frais de souscription déjà documenté pour cet investissement, s'il existe, est pré-rempli ci-dessous — modifiez-le ou videz le montant pour le supprimer."
|
||||||
|
: "Cette plateforme facture des frais (entrée, gestion…). Indiquez le montant TTC constaté pour cette souscription, ou laissez vide si aucun frais ne s'applique cette fois-ci."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Mode de saisie</label>
|
||||||
|
<select value={form.frais_mode_saisie} onChange={e => onFieldChange('frais_mode_saisie', e.target.value)}>
|
||||||
|
<option value="montant">Montant (€)</option>
|
||||||
|
<option value="pourcentage">Pourcentage (%)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>{form.frais_mode_saisie === 'pourcentage' ? 'Frais (% du montant investi)' : 'Montant des frais TTC (€)'}</label>
|
||||||
|
<input type="number" step="0.01" min="0" value={form.frais_valeur}
|
||||||
|
onChange={e => onFieldChange('frais_valeur', e.target.value)}
|
||||||
|
placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: 'span 3' }}>
|
||||||
|
<label>Prélèvement</label>
|
||||||
|
<select value={form.frais_mode_reglement} onChange={e => handleFraisReglementChange(e.target.value)}>
|
||||||
|
<option value="source">À la source (réduit le capital net investi, sans mouvement complémentaire)</option>
|
||||||
|
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
|
||||||
|
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.frais_mode_reglement === 'compte_courant' && (
|
||||||
|
<div>
|
||||||
|
<label>Compte de règlement *</label>
|
||||||
|
{comptesInvestisseur.length > 0 ? (
|
||||||
|
<select required value={form.frais_compte_id} onChange={e => onFieldChange('frais_compte_id', e.target.value)}>
|
||||||
|
<option value="">— Choisir un compte —</option>
|
||||||
|
{comptesInvestisseur.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||||
|
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.frais_mode_reglement === 'source' && form.frais_valeur !== '' && Number(form.frais_valeur) > 0 && (
|
||||||
|
<div style={{ gridColumn: 'span 3' }}>
|
||||||
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: 0 }}>
|
||||||
|
{editingId
|
||||||
|
? "Ce frais est journalisé indépendamment du montant investi — ajustez ce dernier vous-même à l'étape précédente si besoin."
|
||||||
|
: `Capital net qui sera enregistré : ${fmtEUR(Math.max(0, Math.round((Number(form.montant_investi || 0) - (form.frais_mode_saisie === 'pourcentage'
|
||||||
|
? Number(form.montant_investi || 0) * Number(form.frais_valeur) / 100
|
||||||
|
: Number(form.frais_valeur))) * 100) / 100))}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ══ Étape suivante : Catégories, secteurs, autres ══ */}
|
||||||
|
{step === (showFraisStep ? 4 : 3) && (<>
|
||||||
<div style={{ gridColumn: 'span 2' }}>
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
<label>Notes</label>
|
<label>Notes</label>
|
||||||
<textarea rows={3} value={form.notes}
|
<textarea rows={3} value={form.notes}
|
||||||
|
|||||||
@@ -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 (−) ;
|
// 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).
|
// correction → déjà signé en base (montant peut être positif ou négatif).
|
||||||
export function signedMontant(r) {
|
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 (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant);
|
||||||
if (r.type === 'correction') return r.montant;
|
if (r.type === 'correction') return r.montant;
|
||||||
return Math.abs(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_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG',
|
||||||
prelevement_local: "Prélèvement impôts à l'étranger",
|
prelevement_local: "Prélèvement impôts à l'étranger",
|
||||||
interets_plateforme: 'Intérêts plateforme',
|
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 ───────────────────────────
|
/* ── 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
|
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
|
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,
|
remboursement: 1, interets_plateforme: 1, bonus: 1, cashback: 1,
|
||||||
prelevement_local: 1, prelevement_ir: 1, prelevement_csg: 1,
|
prelevement_local: 1, prelevement_ir: 1, prelevement_csg: 1,
|
||||||
depot: 2,
|
depot: 2,
|
||||||
souscription: 3,
|
souscription: 3, frais: 3,
|
||||||
retrait: 4,
|
retrait: 4,
|
||||||
correction: 5,
|
correction: 5,
|
||||||
};
|
};
|
||||||
@@ -104,7 +128,12 @@ const MOUV_FAMILY_RANK = {
|
|||||||
// Position au sein d'une chaîne "remboursement" (lignes partageant le même _rembId)
|
// Position au sein d'une chaîne "remboursement" (lignes partageant le même _rembId)
|
||||||
const MOUV_REMB_CHAIN_POS = {
|
const MOUV_REMB_CHAIN_POS = {
|
||||||
remboursement: 0, interets_plateforme: 0, bonus: 0,
|
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) {
|
export function sortMouvements(list) {
|
||||||
@@ -124,7 +153,7 @@ export function sortMouvements(list) {
|
|||||||
const enriched = list.map(row => {
|
const enriched = list.map(row => {
|
||||||
// Retrait AUTO (sweep compte_courant) : reste collé juste après le remboursement qui le déclenche.
|
// 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) {
|
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
|
// 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é.
|
// 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}`);
|
const sub = subByInvAndDate.get(`${row._invId}|${row.date_operation}`);
|
||||||
if (sub) return { row, family: MOUV_FAMILY_RANK.souscription, anchor: sub._invId, pos: 1 };
|
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)
|
// Lignes d'un remboursement (parent + cashback resté attaché + prélèvements)
|
||||||
if (row._rembId != null) {
|
if (row._rembId != null) {
|
||||||
return {
|
return {
|
||||||
@@ -141,6 +179,14 @@ export function sortMouvements(list) {
|
|||||||
pos: MOUV_REMB_CHAIN_POS[row.type] ?? 0,
|
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)
|
// Souscription / réinvestissement (parent de sa propre chaîne)
|
||||||
if (row.type === 'souscription') {
|
if (row.type === 'souscription') {
|
||||||
return { row, family: MOUV_FAMILY_RANK.souscription, anchor: row._invId, pos: 0 };
|
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 isRemb = row.type === 'remboursement' || isBonus || isCashback || isInteretsPlateforme;
|
||||||
const isSouscription = row.type === 'souscription';
|
const isSouscription = row.type === 'souscription';
|
||||||
const isPrelevement = row.type === 'prelevement_ir' || row.type === 'prelevement_csg' || row.type === 'prelevement_local';
|
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 isAuto = row.source === 'auto_remboursement';
|
||||||
|
|
||||||
const typeLabel = isCorrection ? 'Correction de solde'
|
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_csg' ? 'Prélèvement CSG'
|
||||||
: row.type === 'prelevement_local' ? "Prélèvement impôts à l'étranger"
|
: row.type === 'prelevement_local' ? "Prélèvement impôts à l'étranger"
|
||||||
: isSouscription ? (row._isReinvest ? 'Réinvestissement' : 'Souscription à un investissement')
|
: 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';
|
: row.type === 'depot' ? 'Dépôt' : 'Retrait';
|
||||||
|
|
||||||
const editLabel = isCorrection ? 'Modifier la correction de solde'
|
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 ;
|
// 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.
|
// 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 isPos = isCorrection ? row.montant >= 0 : !isDebitType;
|
||||||
const sign = isPos ? '+' : '−';
|
const sign = isPos ? '+' : '−';
|
||||||
const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`;
|
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' },
|
: 'Étranger — indicatif, aucun prélèvement à la source ici' },
|
||||||
isPrelevement && row.libelle && { label: 'Projet', value: row.libelle },
|
isPrelevement && row.libelle && { label: 'Projet', value: row.libelle },
|
||||||
isSouscription && 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 },
|
!isCorrection && !isRemb && row.reference && { label: 'Référence', value: row.reference },
|
||||||
row.notes && { label: 'Notes', value: row.notes },
|
row.notes && { label: 'Notes', value: row.notes },
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@@ -562,6 +617,34 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
|||||||
</div>
|
</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">
|
<div className="dr-detail-fields">
|
||||||
{fields.map(f => (
|
{fields.map(f => (
|
||||||
<div className="dr-detail-field" key={f.label}>
|
<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)}>
|
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
|
||||||
Voir / modifier le remboursement
|
Voir / modifier le remboursement
|
||||||
</button>
|
</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)}>
|
<button className="dr-detail-edit-btn" onClick={() => onViewInvestissement(row)}>
|
||||||
Voir l'investissement
|
Voir l'investissement
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function getIcons() {
|
|||||||
return _promise;
|
return _promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageIcon({ name, size = 40 }) {
|
export default function PageIcon({ name, size = 40, style }) {
|
||||||
const [filename, setFilename] = useState(() => _cache?.[name] ?? null);
|
const [filename, setFilename] = useState(() => _cache?.[name] ?? null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +38,7 @@ export default function PageIcon({ name, size = 40 }) {
|
|||||||
marginRight: 10,
|
marginRight: 10,
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import { fmtEUR } from '../utils/format.js';
|
|||||||
const round2 = (n) => Math.round(n * 100) / 100;
|
const round2 = (n) => Math.round(n * 100) / 100;
|
||||||
const fmtRate = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
|
const fmtRate = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
|
||||||
|
|
||||||
|
// Le frais ne réduit interets_nets que lorsqu'il est retenu SUR ce remboursement (mode
|
||||||
|
// 'remboursement', par défaut) — un frais réglé à part ('portefeuille'/'compte_courant',
|
||||||
|
// Étape 5 bis, 01/09/26 — cas Baltis) n'a aucun impact ici, cf. computeChamps() côté backend.
|
||||||
|
const fraisReduitBase = (f) => !f.frais_mode_reglement || f.frais_mode_reglement === 'remboursement';
|
||||||
|
|
||||||
const computeInteretsNets = (f) =>
|
const computeInteretsNets = (f) =>
|
||||||
round2(Number(f.interets_bruts || 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
round2(Number(f.interets_bruts || 0) - (fraisReduitBase(f) ? Number(f.frais_ttc || 0) : 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
||||||
|
|
||||||
const computeNet = (f) =>
|
const computeNet = (f) =>
|
||||||
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
|
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
|
||||||
@@ -86,10 +91,20 @@ export default function RembFormModal({
|
|||||||
const hasLocalTax = !isBonus && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
|
const hasLocalTax = !isBonus && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
|
||||||
const isExonere = !isBonus && currentInv?.fiscalite_override === 'exonere';
|
const isExonere = !isBonus && currentInv?.fiscalite_override === 'exonere';
|
||||||
const isIndicatif = Boolean(isExonere || (!isBonus && currentPlat && currentPlat.fiscalite !== 'flat_tax'));
|
const isIndicatif = Boolean(isExonere || (!isBonus && currentPlat && currentPlat.fiscalite !== 'flat_tax'));
|
||||||
|
// Cas indicatif (plateforme étrangère ou exonérée) : le montant versé est net de frais mais
|
||||||
|
// pas des prélèvements (indicatifs) — même formule que net_recu côté backend (computeChamps,
|
||||||
|
// branche isTaxIndicatif) — cf. Étape 4, 31/08/26.
|
||||||
const montantVerse = isIndicatif
|
const montantVerse = isIndicatif
|
||||||
? round2(Number(form.capital || 0) + Number(form.cashback || 0) + Number(form.interets_bruts || 0))
|
? round2(Number(form.capital || 0) + Number(form.cashback || 0) + Number(form.interets_bruts || 0) - (fraisReduitBase(form) ? Number(form.frais_ttc || 0) : 0))
|
||||||
: computeNet(form);
|
: computeNet(form);
|
||||||
|
|
||||||
|
// Section "Frais" (frais de distribution, Étape 4) : visible si la plateforme facture des
|
||||||
|
// frais (référentiel frais_plateforme ≠ 'aucun'), ou si un montant est déjà saisi (garde
|
||||||
|
// l'accès même si la config de la plateforme change ensuite) — même logique que le bloc
|
||||||
|
// "Frais d'opération" de la fiche investissement, cf. project_frais_operation.md.
|
||||||
|
const showFraisSection = !isBonus
|
||||||
|
&& ((currentPlat?.frais_plateforme && currentPlat.frais_plateforme !== 'aucun') || Number(form.frais_ttc) > 0);
|
||||||
|
|
||||||
const modalTitle = title ?? (editingId ? 'Modifier le remboursement' : (locked ? 'Saisir le remboursement' : 'Nouveau remboursement'));
|
const modalTitle = title ?? (editingId ? 'Modifier le remboursement' : (locked ? 'Saisir le remboursement' : 'Nouveau remboursement'));
|
||||||
|
|
||||||
const handleMethodeChange = (m) => {
|
const handleMethodeChange = (m) => {
|
||||||
@@ -97,6 +112,16 @@ export default function RembFormModal({
|
|||||||
onFormPatch({ methode_remboursement: m, compte_id: def ? String(def.id) : '' });
|
onFormPatch({ methode_remboursement: m, compte_id: def ? String(def.id) : '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prélèvement du frais (Étape 5 bis, 01/09/26) : passe par onFieldChange pour déclencher le
|
||||||
|
// recalcul des prélèvements (comme un changement de frais_ttc, cf. setField/setRembField dans
|
||||||
|
// les 3 pages appelantes), puis pré-sélectionne un compte par défaut si on bascule sur
|
||||||
|
// 'compte_courant' — même pattern que handleFraisReglementChange côté investissement.
|
||||||
|
const handleFraisModeChange = (mode) => {
|
||||||
|
onFieldChange('frais_mode_reglement', mode);
|
||||||
|
const def = mode === 'compte_courant' ? (comptesInvestisseur?.find(c => c.type === 'compte_courant') ?? comptesInvestisseur?.[0]) : null;
|
||||||
|
onFormPatch({ frais_compte_id: def ? String(def.id) : '' });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -202,6 +227,53 @@ export default function RembFormModal({
|
|||||||
onChange={e => onFieldChange('cashback', e.target.value)} />
|
onChange={e => onFieldChange('cashback', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showFraisSection && (<>
|
||||||
|
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Frais</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Montant des frais (€ TTC)</label>
|
||||||
|
<input type="number" step="0.01" min="0" value={form.frais_ttc ?? 0}
|
||||||
|
onChange={e => onFieldChange('frais_ttc', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{!isInteretsPlateforme ? (<>
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label>Prélèvement</label>
|
||||||
|
<select value={form.frais_mode_reglement || 'remboursement'} onChange={e => handleFraisModeChange(e.target.value)}>
|
||||||
|
<option value="remboursement">Retenu sur ce remboursement (réduit la base imposable et le montant versé)</option>
|
||||||
|
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
|
||||||
|
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.frais_mode_reglement === 'compte_courant' && (
|
||||||
|
<div style={{ gridColumn: 'span 3' }}>
|
||||||
|
<label>Compte de règlement des frais *</label>
|
||||||
|
{comptesInvestisseur?.length > 0 ? (
|
||||||
|
<select required value={form.frais_compte_id || ''} onChange={e => onFieldChange('frais_compte_id', e.target.value)}>
|
||||||
|
<option value="">— Choisir un compte —</option>
|
||||||
|
{comptesInvestisseur.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||||
|
Aucun compte défini. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'flex-end', fontSize: 12, color: 'var(--text-muted)', paddingBottom: 6 }}>
|
||||||
|
{form.frais_mode_reglement === 'compte_courant' || form.frais_mode_reglement === 'portefeuille'
|
||||||
|
? "Frais réglé à part (comme pour la souscription) — aucun impact sur l'imposition ni le montant versé de ce remboursement ; compté séparément dans le Frais payés et le XIRR net."
|
||||||
|
: "Frais d'entrée/de gestion prélevés par la plateforme sur cette distribution (ex. avis d'opéré) — vient réduire la base imposable des intérêts bruts avant calcul des prélèvements."}
|
||||||
|
</div>
|
||||||
|
</>) : (
|
||||||
|
<div style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'flex-end', fontSize: 12, color: 'var(--text-muted)', paddingBottom: 6 }}>
|
||||||
|
Frais d'entrée/de gestion prélevés par la plateforme sur cette distribution (ex. avis d'opéré) —
|
||||||
|
vient réduire la base imposable des intérêts bruts avant calcul des prélèvements.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Imposition{isIndicatif ? ' — indicatif' : ''}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Imposition{isIndicatif ? ' — indicatif' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export default function Aide() {
|
|||||||
+ Remboursements crédités au porte-monnaie<br />
|
+ Remboursements crédités au porte-monnaie<br />
|
||||||
+ Bonus (parrainage / plateforme)<br />
|
+ Bonus (parrainage / plateforme)<br />
|
||||||
− Capital investi (en cours ou remboursé)<br />
|
− Capital investi (en cours ou remboursé)<br />
|
||||||
|
− Frais (déduits à la souscription ou réglés via le porte-monnaie)<br />
|
||||||
+ Corrections de solde
|
+ Corrections de solde
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -297,10 +298,61 @@ export default function Aide() {
|
|||||||
(y compris les réinvestissements complémentaires) est soustrait du porte-monnaie, car ces fonds ne sont
|
(y compris les réinvestissements complémentaires) est soustrait du porte-monnaie, car ces fonds ne sont
|
||||||
plus disponibles. Ils reviennent progressivement via les remboursements.</p>
|
plus disponibles. Ils reviennent progressivement via les remboursements.</p>
|
||||||
|
|
||||||
|
<p><strong style={{ color: 'var(--text)' }}>Frais</strong> — les frais liés aux investissements (voir la question dédiée
|
||||||
|
ci-dessous) réduisent le solde lorsqu'ils sont réglés « à la source » (déduits du versement lors de la
|
||||||
|
souscription) ou via le porte-monnaie. Les frais réglés depuis un compte courant du détenteur, ou retenus
|
||||||
|
directement sur un remboursement, n'ont aucun impact ici — ils ne transitent jamais par le porte-monnaie de
|
||||||
|
la plateforme.</p>
|
||||||
|
|
||||||
<p style={{ marginBottom: 0 }}><strong style={{ color: 'var(--text)' }}>Corrections de solde</strong> — ajustements manuels
|
<p style={{ marginBottom: 0 }}><strong style={{ color: 'var(--text)' }}>Corrections de solde</strong> — ajustements manuels
|
||||||
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
|
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
|
||||||
</FaqItem>
|
</FaqItem>
|
||||||
|
|
||||||
|
<FaqItem question="Comment fonctionne le suivi des frais liés aux investissements ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
Certaines plateformes facturent des frais en dehors des intérêts et prélèvements fiscaux habituels : frais
|
||||||
|
d'entrée à la souscription, frais de gestion, frais sur une distribution (retenus ou non sur un
|
||||||
|
remboursement), ou autre. Le bloc <em>Frais d'opération</em> de la fiche d'un investissement les recense tous.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Où saisir un frais ?</h4>
|
||||||
|
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||||
|
<li><strong style={{ color: 'var(--text)' }}>Frais de souscription</strong> — depuis l'étape « Frais » du
|
||||||
|
formulaire de création ou modification de l'investissement. Un seul frais de cette catégorie par
|
||||||
|
investissement.</li>
|
||||||
|
<li><strong style={{ color: 'var(--text)' }}>Frais de gestion ou autre</strong> — depuis le bouton
|
||||||
|
<strong style={{ color: 'var(--text)' }}> + Ajouter</strong> du bloc <em>Frais d'opération</em> sur la fiche.</li>
|
||||||
|
<li><strong style={{ color: 'var(--text)' }}>Frais sur distribution</strong> — depuis le champ
|
||||||
|
<strong style={{ color: 'var(--text)' }}> Frais</strong> du formulaire d'un remboursement. La ligne est créée et
|
||||||
|
synchronisée automatiquement dans le bloc <em>Frais d'opération</em> ; elle ne peut être modifiée ou
|
||||||
|
supprimée que depuis ce remboursement, pour éviter toute désynchronisation.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Mode de règlement et impact</h4>
|
||||||
|
<p>Chaque frais est réglé de l'une des quatre façons suivantes, avec un impact différent :</p>
|
||||||
|
<ul style={{ margin: '8px 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||||
|
<li><strong style={{ color: 'var(--text)' }}>Déduit du versement lors de la souscription</strong> — aucune
|
||||||
|
ligne de mouvement séparée : le montant est simplement documenté à titre de traçabilité, mais il réduit
|
||||||
|
le solde du porte-monnaie (voir la question ci-dessus).</li>
|
||||||
|
<li><strong style={{ color: 'var(--text)' }}>Porte-monnaie</strong> — débité du solde disponible sur la
|
||||||
|
plateforme, comme un retrait.</li>
|
||||||
|
<li><strong style={{ color: 'var(--text)' }}>Débité du compte courant</strong> — réglé depuis un compte
|
||||||
|
courant du détenteur (choisi à la saisie), sans aucun impact sur le porte-monnaie de la plateforme.</li>
|
||||||
|
<li><strong style={{ color: 'var(--text)' }}>Déduit du remboursement</strong> — retenu directement sur le
|
||||||
|
remboursement lié : le montant réduit alors la base imposable et les <em>intérêts nets</em>/<em>net reçu</em>
|
||||||
|
de ce remboursement (comportement historique). C'est le seul mode où le frais n'est pas une sortie d'argent
|
||||||
|
séparée à suivre par ailleurs.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
Les frais réglés en <strong style={{ color: 'var(--text)' }}>porte-monnaie</strong> ou
|
||||||
|
<strong style={{ color: 'var(--text)' }}> compte courant</strong> représentent une vraie sortie d'argent,
|
||||||
|
distincte des remboursements : ils sont donc déduits du KPI <strong style={{ color: 'var(--text)' }}>Gain net
|
||||||
|
depuis le début</strong> du tableau de bord (et pris en compte dans le TRI net des investissements
|
||||||
|
concernés), en plus de leur effet sur le solde du porte-monnaie détaillé ci-dessus.
|
||||||
|
</p>
|
||||||
|
</FaqItem>
|
||||||
|
|
||||||
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||||
<p style={{ marginTop: 0 }}>
|
<p style={{ marginTop: 0 }}>
|
||||||
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
|
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
|
||||||
@@ -621,7 +673,7 @@ export default function Aide() {
|
|||||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_dashboard</code>
|
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_dashboard</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits — filtrable par année.</td>
|
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits, gain net depuis le début (frais hors remboursement déduits) — filtrable par année.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
@@ -633,13 +685,19 @@ export default function Aide() {
|
|||||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investissement</code>
|
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investissement</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris la liste de ses remboursements réels perçus.</td>
|
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris ses remboursements réels perçus et les frais qui lui sont liés (souscription, gestion, distribution).</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_remboursements</code>
|
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_remboursements</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), filtrable par période.</td>
|
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), avec le frais éventuellement retenu sur chacun, filtrable par période.</td>
|
||||||
|
</tr>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
|
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_frais_operations</code>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Frais liés aux investissements — souscription, gestion, distribution — toutes plateformes, filtrable par période.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
@@ -684,6 +742,7 @@ export default function Aide() {
|
|||||||
<li>« Donne-moi le détail de l'investissement 42, avec ses remboursements. »</li>
|
<li>« Donne-moi le détail de l'investissement 42, avec ses remboursements. »</li>
|
||||||
<li>« Quels remboursements ai-je reçus entre le 1er et le 30 juin 2026 ? »</li>
|
<li>« Quels remboursements ai-je reçus entre le 1er et le 30 juin 2026 ? »</li>
|
||||||
<li>« Quels ont été mes derniers dépôts et retraits ? »</li>
|
<li>« Quels ont été mes derniers dépôts et retraits ? »</li>
|
||||||
|
<li>« Combien ai-je payé de frais cette année, et sur quels investissements ? »</li>
|
||||||
<li>« Regarde cette annonce de projet et propose-moi les infos pour créer l'investissement : [URL] »
|
<li>« Regarde cette annonce de projet et propose-moi les infos pour créer l'investissement : [URL] »
|
||||||
(développement local, avec <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> activé)</li>
|
(développement local, avec <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> activé)</li>
|
||||||
<li>« Ai-je atteint mon objectif de versement pour {new Date().getFullYear()} ? »</li>
|
<li>« Ai-je atteint mon objectif de versement pour {new Date().getFullYear()} ? »</li>
|
||||||
@@ -691,7 +750,7 @@ export default function Aide() {
|
|||||||
</ul>
|
</ul>
|
||||||
<p style={{ marginBottom: 0 }}>
|
<p style={{ marginBottom: 0 }}>
|
||||||
Ces exemples fonctionnent aussi bien en dev qu'en prod dès lors que le serveur correspondant est connecté
|
Ces exemples fonctionnent aussi bien en dev qu'en prod dès lors que le serveur correspondant est connecté
|
||||||
(voir les FAQ de configuration ci-dessus) — les huit premiers outils sont identiques dans les deux environnements.
|
(voir les FAQ de configuration ci-dessus) — les neuf premiers outils sont identiques dans les deux environnements.
|
||||||
</p>
|
</p>
|
||||||
</FaqItem>
|
</FaqItem>
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function KpiCard({ title, value, badge, refValue, note, onClick, hint }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── KPI filtrés par année ── */
|
/* ── KPI filtrés par année ── */
|
||||||
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements, allRembs, allReinvests, allSimulRembs }) {
|
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements, allRembs, allReinvests, allSimulRembs, allFraisOps }) {
|
||||||
// Montant total déjà déclaré en perte définitive (tous statuts confondus,
|
// Montant total déjà déclaré en perte définitive (tous statuts confondus,
|
||||||
// seuls les dossiers passés en perte_definitive ont un capital_perdu > 0) —
|
// seuls les dossiers passés en perte_definitive ont un capital_perdu > 0) —
|
||||||
// affiché en complément du KPI "Capital en risque", qui les exclut déjà
|
// affiché en complément du KPI "Capital en risque", qui les exclut déjà
|
||||||
@@ -360,8 +360,19 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
interets_nets: acc.interets_nets + (r.interets_nets || 0),
|
interets_nets: acc.interets_nets + (r.interets_nets || 0),
|
||||||
cashback: acc.cashback + (r.cashback || 0),
|
cashback: acc.cashback + (r.cashback || 0),
|
||||||
}), { interets_nets: 0, cashback: 0 });
|
}), { interets_nets: 0, cashback: 0 });
|
||||||
return totals.interets_nets + totals.cashback + (correctionsTotal || 0);
|
// Frais réglés en dehors des montants déjà nettés (frais 'source' à la souscription,
|
||||||
}, [rawDataGlobal, correctionsTotal]);
|
// et frais liés à un remboursement déjà déduits de interets_nets/net_recu) : mode
|
||||||
|
// 'portefeuille' ou 'compte_courant' — vraie sortie d'argent pour l'investisseur,
|
||||||
|
// jusque-là absente de ce total (même trou que celui corrigé sur le XIRR net à
|
||||||
|
// l'Étape 5, cf. xirrGlobal plus bas — signalé par Olivier le 01/09/26).
|
||||||
|
const idsGainNet = new Set((investissements || []).map(r => r.id));
|
||||||
|
const fraisHorsRembTotal = (allFraisOps || []).reduce((sum, fo) => {
|
||||||
|
if (!idsGainNet.has(fo.investissement_id)) return sum;
|
||||||
|
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') return sum;
|
||||||
|
return sum + (fo.montant || 0);
|
||||||
|
}, 0);
|
||||||
|
return totals.interets_nets + totals.cashback + (correctionsTotal || 0) - fraisHorsRembTotal;
|
||||||
|
}, [rawDataGlobal, correctionsTotal, investissements, allFraisOps]);
|
||||||
|
|
||||||
// Valeur "Gain net" telle qu'elle aurait été affichée fin du mois précédent —
|
// Valeur "Gain net" telle qu'elle aurait été affichée fin du mois précédent —
|
||||||
// on retire du cumul total la part perçue ce mois-ci (intérêts nets + cashback,
|
// on retire du cumul total la part perçue ce mois-ci (intérêts nets + cashback,
|
||||||
@@ -369,9 +380,22 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
// n'ont pas de granularité mensuelle côté backend ; elles restent donc dans le
|
// n'ont pas de granularité mensuelle côté backend ; elles restent donc dans le
|
||||||
// cumul "avant ce mois", ce qui est sans incidence pratique (montants marginaux).
|
// cumul "avant ce mois", ce qui est sans incidence pratique (montants marginaux).
|
||||||
const gainNetPrevMonthEnd = useMemo(() => {
|
const gainNetPrevMonthEnd = useMemo(() => {
|
||||||
const deltaCeMois = (thisMonthRow?.interets_nets || 0) + (thisMonthRow?.cashback || 0);
|
// Même granularité mensuelle que interets_nets/cashback pour les frais hors
|
||||||
|
// remboursement (portefeuille/compte_courant) : contrairement aux corrections de
|
||||||
|
// solde (jamais datées finement), frais_operations porte une vraie date_operation —
|
||||||
|
// on peut donc isoler la part du mois en cours plutôt que de la laisser polluer tout
|
||||||
|
// le delta M vs M-1 (cf. gainNetTotal ci-dessus).
|
||||||
|
const thisMonthStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}`;
|
||||||
|
const idsGainNet = new Set((investissements || []).map(r => r.id));
|
||||||
|
const fraisCeMois = (allFraisOps || []).reduce((sum, fo) => {
|
||||||
|
if (!idsGainNet.has(fo.investissement_id)) return sum;
|
||||||
|
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') return sum;
|
||||||
|
if (!fo.date_operation?.startsWith(thisMonthStr)) return sum;
|
||||||
|
return sum + (fo.montant || 0);
|
||||||
|
}, 0);
|
||||||
|
const deltaCeMois = (thisMonthRow?.interets_nets || 0) + (thisMonthRow?.cashback || 0) - fraisCeMois;
|
||||||
return gainNetTotal - deltaCeMois;
|
return gainNetTotal - deltaCeMois;
|
||||||
}, [gainNetTotal, thisMonthRow]);
|
}, [gainNetTotal, thisMonthRow, currentYear, currentMonth, investissements, allFraisOps]);
|
||||||
|
|
||||||
// ── XIRR Global (réel + attendu) — depuis le début, indépendant du sélecteur
|
// ── XIRR Global (réel + attendu) — depuis le début, indépendant du sélecteur
|
||||||
// d'année (même logique que "Gain net depuis le début" ci-dessus). Reprend
|
// d'année (même logique que "Gain net depuis le début" ci-dessus). Reprend
|
||||||
@@ -430,6 +454,16 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
||||||
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
||||||
}
|
}
|
||||||
|
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
|
||||||
|
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
|
||||||
|
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
|
||||||
|
for (const fo of (allFraisOps || [])) {
|
||||||
|
if (!ids.has(fo.investissement_id)) continue;
|
||||||
|
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
|
||||||
|
const d = fo.date_operation?.slice(0, 10);
|
||||||
|
if (!d || d > cutoff) continue;
|
||||||
|
flowsNet.push({ amount: -(fo.montant || 0), date: d });
|
||||||
|
}
|
||||||
let estime = false;
|
let estime = false;
|
||||||
for (const r of invs) {
|
for (const r of invs) {
|
||||||
const capInv = r.montant_investi + (reinvestCumulParInvGlobal[r.id] || 0);
|
const capInv = r.montant_investi + (reinvestCumulParInvGlobal[r.id] || 0);
|
||||||
@@ -445,7 +479,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
flowsBrut.sort(byDate);
|
flowsBrut.sort(byDate);
|
||||||
flowsNet.sort(byDate);
|
flowsNet.sort(byDate);
|
||||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
|
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
|
||||||
}, [investissements, allRembs, allReinvests, reinvestCumulParInvGlobal, capRembParInvGlobal]);
|
}, [investissements, allRembs, allReinvests, allFraisOps, reinvestCumulParInvGlobal, capRembParInvGlobal]);
|
||||||
|
|
||||||
const xirrGlobalAttendu = useMemo(() => {
|
const xirrGlobalAttendu = useMemo(() => {
|
||||||
const cutoff = today();
|
const cutoff = today();
|
||||||
@@ -472,6 +506,16 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
||||||
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
||||||
}
|
}
|
||||||
|
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
|
||||||
|
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
|
||||||
|
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
|
||||||
|
for (const fo of (allFraisOps || [])) {
|
||||||
|
if (!ids.has(fo.investissement_id)) continue;
|
||||||
|
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
|
||||||
|
const d = fo.date_operation?.slice(0, 10);
|
||||||
|
if (!d || d > cutoff) continue;
|
||||||
|
flowsNet.push({ amount: -(fo.montant || 0), date: d });
|
||||||
|
}
|
||||||
const futureSimulParInv = {};
|
const futureSimulParInv = {};
|
||||||
for (const sr of (allSimulRembs || [])) {
|
for (const sr of (allSimulRembs || [])) {
|
||||||
if (!ids.has(sr.investissement_id)) continue;
|
if (!ids.has(sr.investissement_id)) continue;
|
||||||
@@ -504,7 +548,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
flowsNet.sort(byDate);
|
flowsNet.sort(byDate);
|
||||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
|
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [investissements, allRembs, allReinvests, allSimulRembs, reinvestCumulParInvGlobal, capRembParInvGlobal, pfuRates]);
|
}, [investissements, allRembs, allReinvests, allFraisOps, allSimulRembs, reinvestCumulParInvGlobal, capRembParInvGlobal, pfuRates]);
|
||||||
|
|
||||||
// ── Valeur affichée ──
|
// ── Valeur affichée ──
|
||||||
const getValue = (field) => {
|
const getValue = (field) => {
|
||||||
@@ -640,7 +684,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Gain net depuis le début"
|
title="Gain net depuis le début"
|
||||||
hint="Intérêts nets + cashback + bonus parrainage/plateforme + corrections de solde, cumulés depuis le début — indépendant du capital encore investi ou du solde actuel sur les plateformes (reste juste même en cas de retrait complet)."
|
hint="Intérêts nets + cashback + bonus parrainage/plateforme + corrections de solde, moins les frais réglés hors remboursement (porte-monnaie ou compte courant du détenteur), cumulés depuis le début — indépendant du capital encore investi ou du solde actuel sur les plateformes (reste juste même en cas de retrait complet)."
|
||||||
value={fmtEUR(gainNetTotal)}
|
value={fmtEUR(gainNetTotal)}
|
||||||
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
|
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
|
||||||
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
|
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
|
||||||
@@ -648,10 +692,11 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
{(() => {
|
{(() => {
|
||||||
const rendement = netMode ? xirrGlobal.net : xirrGlobal.brut;
|
const rendement = netMode ? xirrGlobal.net : xirrGlobal.brut;
|
||||||
const rendementAttendu = netMode ? xirrGlobalAttendu.net : xirrGlobalAttendu.brut;
|
const rendementAttendu = netMode ? xirrGlobalAttendu.net : xirrGlobalAttendu.brut;
|
||||||
|
const fraisExplication = ` Les frais réglés hors remboursement (via le porte-monnaie ou un compte courant) sont eux aussi comptés comme une sortie d'argent dans ce calcul net — en plus des frais déjà déduits à la source ou d'un remboursement, toujours pris en compte.`;
|
||||||
const xirrExplication =
|
const xirrExplication =
|
||||||
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
|
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
|
||||||
`des prêts du portefeuille (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}), ` +
|
`des prêts du portefeuille (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}), ` +
|
||||||
`depuis le début. Chaque flux est pondéré par sa date exacte.\n\n` +
|
`depuis le début. Chaque flux est pondéré par sa date exacte.` + (netMode ? fraisExplication : '') + `\n\n` +
|
||||||
(xirrGlobal.estime
|
(xirrGlobal.estime
|
||||||
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé à aujourd'hui — c'est donc une estimation, pas un taux définitif.`
|
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé à aujourd'hui — c'est donc une estimation, pas un taux définitif.`
|
||||||
: 'Tous les prêts du portefeuille sont soldés : taux définitif.');
|
: 'Tous les prêts du portefeuille sont soldés : taux définitif.');
|
||||||
@@ -660,7 +705,7 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
|
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
|
||||||
`que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` +
|
`que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` +
|
||||||
`date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` +
|
`date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` +
|
||||||
`"comme racheté aujourd'hui", sans compter les intérêts futurs.\n\n` +
|
`"comme racheté aujourd'hui", sans compter les intérêts futurs.` + (netMode ? fraisExplication : '') + `\n\n` +
|
||||||
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` +
|
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` +
|
||||||
`(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` +
|
`(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` +
|
||||||
`valorisé de la même façon que dans le XIRR réel.`;
|
`valorisé de la même façon que dans le XIRR réel.`;
|
||||||
@@ -736,6 +781,7 @@ export default function Dashboard() {
|
|||||||
const [allRembs, setAllRembs] = useState([]);
|
const [allRembs, setAllRembs] = useState([]);
|
||||||
const [allReinvests, setAllReinvests] = useState([]);
|
const [allReinvests, setAllReinvests] = useState([]);
|
||||||
const [allSimulRembs, setAllSimulRembs] = useState([]);
|
const [allSimulRembs, setAllSimulRembs] = useState([]);
|
||||||
|
const [allFraisOps, setAllFraisOps] = useState([]);
|
||||||
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
|
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
|
||||||
|
|
||||||
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
|
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
|
||||||
@@ -792,6 +838,10 @@ export default function Dashboard() {
|
|||||||
api.get('/remboursements', scopeParams).then(setAllRembs).catch(() => setAllRembs([])),
|
api.get('/remboursements', scopeParams).then(setAllRembs).catch(() => setAllRembs([])),
|
||||||
api.get('/reinvestissements', { scope: 'all' }).then(setAllReinvests).catch(() => setAllReinvests([])),
|
api.get('/reinvestissements', { scope: 'all' }).then(setAllReinvests).catch(() => setAllReinvests([])),
|
||||||
api.get('/simul/all', scopeParams).then(setAllSimulRembs).catch(() => setAllSimulRembs([])),
|
api.get('/simul/all', scopeParams).then(setAllSimulRembs).catch(() => setAllSimulRembs([])),
|
||||||
|
// Nécessaire au XIRR net (frais réglés par portefeuille/compte courant, cf. Étape 5) —
|
||||||
|
// sans investissement_id, ne connaît que l'utilisateur (tous investisseurs), filtré
|
||||||
|
// ensuite via `ids` au moment du calcul, même pattern que allFraisOps dans Plateformes.jsx.
|
||||||
|
api.get('/frais-operations').then(setAllFraisOps).catch(() => setAllFraisOps([])),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -849,7 +899,7 @@ export default function Dashboard() {
|
|||||||
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
|
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
|
||||||
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
|
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
|
||||||
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
|
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
|
||||||
allRembs={allRembs} allReinvests={allReinvests} allSimulRembs={allSimulRembs}
|
allRembs={allRembs} allReinvests={allReinvests} allSimulRembs={allSimulRembs} allFraisOps={allFraisOps}
|
||||||
/>
|
/>
|
||||||
<YearSelectorKpi />
|
<YearSelectorKpi />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export default function DepotsRetraits() {
|
|||||||
const [allRemb, setAllRemb] = useState([]);
|
const [allRemb, setAllRemb] = useState([]);
|
||||||
const [allInv, setAllInv] = useState([]);
|
const [allInv, setAllInv] = useState([]);
|
||||||
const [allReinvests, setAllReinvests] = useState([]);
|
const [allReinvests, setAllReinvests] = useState([]);
|
||||||
|
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via invById, cf. reinvestissements)
|
||||||
|
|
||||||
const [allCorrections, setAllCorrections] = useState([]);
|
const [allCorrections, setAllCorrections] = useState([]);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||||
@@ -176,7 +177,7 @@ export default function DepotsRetraits() {
|
|||||||
setDashData(null);
|
setDashData(null);
|
||||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
|
||||||
try {
|
try {
|
||||||
const [r, p, d, remb, inv, corr, reinvests] = await Promise.all([
|
const [r, p, d, remb, inv, corr, reinvests, fraisOps] = await Promise.all([
|
||||||
api.get('/depots-retraits', scopeParams),
|
api.get('/depots-retraits', scopeParams),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/dashboard', scopeParams),
|
api.get('/dashboard', scopeParams),
|
||||||
@@ -186,9 +187,13 @@ export default function DepotsRetraits() {
|
|||||||
// Toujours scope 'all' — filtré côté client via allInv (déjà scopé) pour respecter
|
// 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).
|
// le même pattern que Plateformes.jsx (route /reinvestissements ne connaît que l'utilisateur).
|
||||||
api.get('/reinvestissements', { scope: 'all' }),
|
api.get('/reinvestissements', { scope: 'all' }),
|
||||||
|
// /frais-operations sans investissement_id ne connaît lui aussi que l'utilisateur —
|
||||||
|
// même pattern, filtré côté client via invById.
|
||||||
|
api.get('/frais-operations').catch(() => []),
|
||||||
]);
|
]);
|
||||||
setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr);
|
setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr);
|
||||||
setAllReinvests(Array.isArray(reinvests) ? reinvests : []);
|
setAllReinvests(Array.isArray(reinvests) ? reinvests : []);
|
||||||
|
setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('DepotsRetraits load error:', e);
|
console.error('DepotsRetraits load error:', e);
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
@@ -453,6 +458,48 @@ export default function DepotsRetraits() {
|
|||||||
return true;
|
return true;
|
||||||
}), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]);
|
}), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]);
|
||||||
|
|
||||||
|
// Frais d'opération (Étape 3, 31/08/26) : une ligne débit par frais_operations, rattachée à
|
||||||
|
// l'investissement concerné via investissement_id → invById (même pattern que les
|
||||||
|
// réinvestissements : la route ne connaît que l'utilisateur, filtrage client via allInv déjà
|
||||||
|
// scopé par investisseur actif). 'compte_courant' est affiché quand même (traçabilité) mais
|
||||||
|
// n'a aucun effet sur le solde (cf. signedMontant dans MouvementsShared.jsx).
|
||||||
|
const normalizedFraisMouvements = useMemo(() => {
|
||||||
|
const out = [];
|
||||||
|
for (const fr of allFraisOps) {
|
||||||
|
const inv = invById.get(fr.investissement_id);
|
||||||
|
if (!inv) continue; // hors scope investisseur actif
|
||||||
|
out.push({
|
||||||
|
id: `frais_${fr.id}`,
|
||||||
|
_fraisId: fr.id,
|
||||||
|
_invId: inv.id,
|
||||||
|
_rembId: fr.remboursement_id || null,
|
||||||
|
plateforme_id: inv.plateforme_id,
|
||||||
|
plateforme_nom: inv.plateforme_nom,
|
||||||
|
plateforme_detenteur_nom: inv.plateforme_detenteur_nom,
|
||||||
|
date_operation: fr.date_operation,
|
||||||
|
type: 'frais',
|
||||||
|
montant: fr.montant,
|
||||||
|
mode_reglement: fr.mode_reglement,
|
||||||
|
compte_nom: fr.compte_nom || null,
|
||||||
|
origine: fr.origine,
|
||||||
|
categorie: fr.categorie,
|
||||||
|
libelle: inv.nom_projet || '',
|
||||||
|
source: 'frais_operation',
|
||||||
|
investisseur_id: inv.investisseur_id ?? null,
|
||||||
|
notes: fr.notes || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [allFraisOps, invById]);
|
||||||
|
|
||||||
|
const filteredFraisMouvements = useMemo(() => normalizedFraisMouvements.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;
|
||||||
|
}), [normalizedFraisMouvements, filter.plateforme_ids, filter.year, filter.month]);
|
||||||
|
|
||||||
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
|
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
|
||||||
// COMPLET et NON FILTRÉ (allRows/normalizedCorrections/normalizedRembMouvements/
|
// COMPLET et NON FILTRÉ (allRows/normalizedCorrections/normalizedRembMouvements/
|
||||||
// normalizedSouscriptionMouvements, pas rows/filteredCorrections/...), sinon le cumul
|
// normalizedSouscriptionMouvements, pas rows/filteredCorrections/...), sinon le cumul
|
||||||
@@ -461,7 +508,8 @@ export default function DepotsRetraits() {
|
|||||||
// Voir [[project_mouvements_solde_indicatif]].
|
// Voir [[project_mouvements_solde_indicatif]].
|
||||||
const soldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
|
const soldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
|
||||||
...allRows, ...normalizedCorrections, ...normalizedRembMouvements, ...normalizedSouscriptionMouvements,
|
...allRows, ...normalizedCorrections, ...normalizedRembMouvements, ...normalizedSouscriptionMouvements,
|
||||||
]), [allRows, normalizedCorrections, normalizedRembMouvements, normalizedSouscriptionMouvements]);
|
...normalizedFraisMouvements,
|
||||||
|
]), [allRows, normalizedCorrections, normalizedRembMouvements, normalizedSouscriptionMouvements, normalizedFraisMouvements]);
|
||||||
|
|
||||||
// tableRows : dépôts/retraits + corrections + remboursements/bonus + souscriptions, triés par date desc.
|
// 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,
|
// _projet : nom du projet quand cela a du sens (remboursement, souscription/réinvestissement,
|
||||||
@@ -480,9 +528,12 @@ export default function DepotsRetraits() {
|
|||||||
const subPart = filteredSouscriptionMouvements
|
const subPart = filteredSouscriptionMouvements
|
||||||
.filter(r => !filter.type || r.type === filter.type)
|
.filter(r => !filter.type || r.type === filter.type)
|
||||||
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||||
return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart])
|
const fraisPart = filteredFraisMouvements
|
||||||
|
.filter(r => !filter.type || r.type === filter.type)
|
||||||
|
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||||
|
return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart])
|
||||||
.map(r => ({ ...r, _soldeIndicatif: soldeIndicatifByRowId.get(r.id) }));
|
.map(r => ({ ...r, _soldeIndicatif: soldeIndicatifByRowId.get(r.id) }));
|
||||||
}, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]);
|
}, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filteredFraisMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]);
|
||||||
|
|
||||||
/* ── Pagination mouvements ── */
|
/* ── Pagination mouvements ── */
|
||||||
const {
|
const {
|
||||||
@@ -1296,6 +1347,7 @@ export default function DepotsRetraits() {
|
|||||||
<option value="cashback">Cashback</option>
|
<option value="cashback">Cashback</option>
|
||||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||||
<option value="souscription">Souscriptions</option>
|
<option value="souscription">Souscriptions</option>
|
||||||
|
<option value="frais">Frais d'opération</option>
|
||||||
<option value="prelevement_ir">Prélèvements IR</option>
|
<option value="prelevement_ir">Prélèvements IR</option>
|
||||||
<option value="prelevement_csg">Prélèvements CSG</option>
|
<option value="prelevement_csg">Prélèvements CSG</option>
|
||||||
<option value="prelevement_local">Prélèvements étranger</option>
|
<option value="prelevement_local">Prélèvements étranger</option>
|
||||||
@@ -1508,6 +1560,55 @@ export default function DepotsRetraits() {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (r.type === 'frais') {
|
||||||
|
const horsPM = r.mode_reglement === 'compte_courant';
|
||||||
|
const rembLinked = r.mode_reglement === 'remboursement';
|
||||||
|
return (
|
||||||
|
<tr key={r.id}
|
||||||
|
className={`dr-row${selectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||||
|
onClick={() => setSelectedRow(selectedRow?.id === r.id ? null : r)}
|
||||||
|
style={{ cursor: 'pointer', opacity: horsPM ? 0.75 : 1 }}>
|
||||||
|
<td>{fmtDate(r.date_operation)}</td>
|
||||||
|
<td>{r.plateforme_nom}</td>
|
||||||
|
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
|
||||||
|
<td>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
|
||||||
|
padding: '2px 7px', borderRadius: 4,
|
||||||
|
background: 'color-mix(in srgb, #f59e0b 15%, transparent)',
|
||||||
|
color: '#f59e0b',
|
||||||
|
}}>frais</span>
|
||||||
|
{horsPM && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||||
|
padding: '1px 5px', borderRadius: 3,
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} title="Réglé via un compte courant — aucun effet sur le solde du porte-monnaie">HORS PM</span>
|
||||||
|
)}
|
||||||
|
{rembLinked && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||||
|
padding: '1px 5px', borderRadius: 3,
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} title="Déjà déduit du montant net du remboursement — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT REMB.</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted">{r._projet || '—'}</td>
|
||||||
|
<td className={horsPM ? 'num text-muted' : 'num danger'}>
|
||||||
|
− {fmtEUR(r.montant)}
|
||||||
|
</td>
|
||||||
|
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
|
||||||
|
<td style={{ width: 36, textAlign: 'center' }} />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
const isAuto = r.source === 'auto_remboursement';
|
const isAuto = r.source === 'auto_remboursement';
|
||||||
return (
|
return (
|
||||||
<tr key={r.id}
|
<tr key={r.id}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Modal from '../components/Modal.jsx';
|
|||||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||||
import RembFormModal from '../components/RembFormModal.jsx';
|
import RembFormModal from '../components/RembFormModal.jsx';
|
||||||
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||||
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||||
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
|
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
|
||||||
@@ -21,6 +22,44 @@ const emptyForm = {
|
|||||||
echeance_fin_de_mois: false,
|
echeance_fin_de_mois: false,
|
||||||
methode_remboursement: '', nom_compte_courant: '', compte_id: '',
|
methode_remboursement: '', nom_compte_courant: '', compte_id: '',
|
||||||
categories_inv_ids: [], secteurs_inv_ids: [],
|
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||||
|
// Frais liés à la plateforme (étape conditionnelle du formulaire, visible aussi en
|
||||||
|
// modification depuis le 31/08/26 — cf. project_frais_operation.md). frais_id : id de
|
||||||
|
// l'entrée frais_operations de catégorie 'souscription' déjà existante, pré-remplie par
|
||||||
|
// openEdit ; pilote l'upsert au submit, jamais affiché tel quel dans le formulaire.
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Formulaire du bloc "Frais d'opération" (cf. project_frais_operation.md) — indépendant du
|
||||||
|
* formulaire principal de l'investissement, sa propre modale légère. Réservé aux catégories
|
||||||
|
* gestion/distribution/autre : la catégorie "souscription" est unique par investissement et
|
||||||
|
* se saisit/modifie uniquement depuis l'étape "Frais" du formulaire "Modifier l'investissement"
|
||||||
|
* (décision Olivier, 31/08/26) — ce bloc ne l'affiche qu'en lecture seule. Saisie libre : la
|
||||||
|
* date par défaut est celle du jour (pas la date de souscription — revu le 31/08/26 après un
|
||||||
|
* premier essai qui la calait sur la souscription, jugé incohérent maintenant que ce bloc ne
|
||||||
|
* gère plus cette catégorie). */
|
||||||
|
function emptyFraisForm() {
|
||||||
|
return { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', origine: 'manuel', categorie: 'autre' };
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
const FRAIS_ORIGINE_LABELS = {
|
||||||
|
souscription: 'Souscription',
|
||||||
|
remboursement: 'Remboursement',
|
||||||
|
manuel: 'Manuel',
|
||||||
|
};
|
||||||
|
// Catégorie de frais (31/08/26, demande Olivier) — cf. FRAIS_MODE_LABELS ci-dessus pour le
|
||||||
|
// pattern. "Souscription" n'apparaît jamais dans le sélecteur de ce bloc (cf. doc ci-dessus),
|
||||||
|
// seulement dans ce libellé pour l'affichage en lecture seule des entrées existantes.
|
||||||
|
const FRAIS_CATEGORIE_LABELS = {
|
||||||
|
souscription: 'Souscription',
|
||||||
|
gestion: 'Gestion',
|
||||||
|
distribution: 'Distribution',
|
||||||
|
autre: 'Autre',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,20 +199,37 @@ export default function InvestissementDetail() {
|
|||||||
const [docsMenu, setDocsMenu] = useState(null);
|
const [docsMenu, setDocsMenu] = useState(null);
|
||||||
const [docsErr, setDocsErr] = useState(null);
|
const [docsErr, setDocsErr] = useState(null);
|
||||||
const [uploadingDoc, setUploadingDoc] = useState(false);
|
const [uploadingDoc, setUploadingDoc] = useState(false);
|
||||||
|
// Bloc "Frais d'opération" (traçabilité des frais liés à la plateforme, cf. project_frais_operation.md)
|
||||||
|
const [fraisOperations, setFraisOperations] = useState([]);
|
||||||
|
const [fraisMenu, setFraisMenu] = useState(null);
|
||||||
|
const [fraisModalOpen, setFraisModalOpen] = useState(false);
|
||||||
|
const [editingFraisId, setEditingFraisId] = useState(null);
|
||||||
|
const [fraisForm, setFraisForm] = useState(emptyFraisForm());
|
||||||
|
const [fraisErr, setFraisErr] = useState(null);
|
||||||
|
const [comptesFraisInvestisseur, setComptesFraisInvestisseur] = useState([]);
|
||||||
const [renamingDocId, setRenamingDocId] = useState(null);
|
const [renamingDocId, setRenamingDocId] = useState(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
const docFileInputRef = useRef(null);
|
const docFileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Charge les comptes du détenteur pour le formulaire "Frais d'opération"
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fraisModalOpen || !inv?.investisseur_id) { setComptesFraisInvestisseur([]); return; }
|
||||||
|
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
|
||||||
|
.then(comptes => setComptesFraisInvestisseur(comptes))
|
||||||
|
.catch(() => setComptesFraisInvestisseur([]));
|
||||||
|
}, [fraisModalOpen]); /* eslint-disable-next-line */
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [data, p, comptes, catInv, sectInv, docs] = await Promise.all([
|
const [data, p, comptes, catInv, sectInv, docs, fraisOps] = await Promise.all([
|
||||||
api.get(`/investissements/${id}`),
|
api.get(`/investissements/${id}`),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/investissements/comptes-courants'),
|
api.get('/investissements/comptes-courants'),
|
||||||
api.get('/categories-inv'),
|
api.get('/categories-inv'),
|
||||||
api.get('/secteurs-inv'),
|
api.get('/secteurs-inv'),
|
||||||
api.get('/documents', { entity_type: 'investissement', entity_id: id }),
|
api.get('/documents', { entity_type: 'investissement', entity_id: id }),
|
||||||
|
api.get('/frais-operations', { investissement_id: id }).catch(() => []),
|
||||||
]);
|
]);
|
||||||
setInv(data);
|
setInv(data);
|
||||||
setPlats(p);
|
setPlats(p);
|
||||||
@@ -181,9 +237,18 @@ export default function InvestissementDetail() {
|
|||||||
setCategoriesInv(catInv);
|
setCategoriesInv(catInv);
|
||||||
setSecteursInv(sectInv);
|
setSecteursInv(sectInv);
|
||||||
setDocuments(docs);
|
setDocuments(docs);
|
||||||
|
setFraisOperations(fraisOps);
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Recharge uniquement la liste des frais d'opération (après ajout/édition/suppression). */
|
||||||
|
const refreshFraisOperations = async () => {
|
||||||
|
try {
|
||||||
|
const fraisOps = await api.get('/frais-operations', { investissement_id: id });
|
||||||
|
setFraisOperations(fraisOps);
|
||||||
|
} catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ }
|
||||||
|
};
|
||||||
|
|
||||||
/** Recharge uniquement la liste des documents (après upload/renommage/suppression) —
|
/** Recharge uniquement la liste des documents (après upload/renommage/suppression) —
|
||||||
* plus léger qu'un load() complet qui recharge aussi l'investissement, les plateformes,
|
* plus léger qu'un load() complet qui recharge aussi l'investissement, les plateformes,
|
||||||
* les comptes courants et les catégories/secteurs. */
|
* les comptes courants et les catégories/secteurs. */
|
||||||
@@ -258,6 +323,71 @@ export default function InvestissementDetail() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Bloc "Frais d'opération" ──────────────────────────────────
|
||||||
|
* Traçabilité des frais liés à la plateforme (entrée, gestion…), décorrélée de la modale
|
||||||
|
* d'investissement — cf. project_frais_operation.md. Toute entrée créée ou modifiée ici est
|
||||||
|
* indépendante de montant_investi : c'est à l'utilisateur d'ajuster ce dernier lui-même
|
||||||
|
* (via "Modifier l'investissement") si un frais doit réduire le capital net.
|
||||||
|
*/
|
||||||
|
const openFraisModal = () => {
|
||||||
|
setEditingFraisId(null);
|
||||||
|
setFraisForm(emptyFraisForm());
|
||||||
|
setFraisErr(null);
|
||||||
|
setFraisModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// N'ouvre jamais cette modale pour une entrée de catégorie 'souscription' — le bouton
|
||||||
|
// Modifier de la liste est masqué pour ces lignes (cf. rendu de la liste plus bas), donc
|
||||||
|
// cette fonction n'est en pratique jamais appelée avec une telle entrée.
|
||||||
|
const openEditFraisModal = (f) => {
|
||||||
|
setEditingFraisId(f.id);
|
||||||
|
setFraisForm({
|
||||||
|
montant: f.montant,
|
||||||
|
mode_reglement: f.mode_reglement,
|
||||||
|
compte_id: f.compte_id || '',
|
||||||
|
date_operation: f.date_operation,
|
||||||
|
notes: f.notes || '',
|
||||||
|
origine: f.origine || 'manuel',
|
||||||
|
categorie: f.categorie || 'autre',
|
||||||
|
});
|
||||||
|
setFraisErr(null);
|
||||||
|
setFraisModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); };
|
||||||
|
|
||||||
|
const submitFrais = async (e) => {
|
||||||
|
e?.preventDefault?.();
|
||||||
|
setFraisErr(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
investissement_id: Number(id),
|
||||||
|
montant: Number(fraisForm.montant),
|
||||||
|
mode_reglement: fraisForm.mode_reglement,
|
||||||
|
compte_id: fraisForm.mode_reglement === 'compte_courant' && fraisForm.compte_id ? Number(fraisForm.compte_id) : null,
|
||||||
|
date_operation: fraisForm.date_operation,
|
||||||
|
origine: fraisForm.origine || 'manuel',
|
||||||
|
categorie: fraisForm.categorie || 'autre',
|
||||||
|
notes: fraisForm.notes || undefined,
|
||||||
|
};
|
||||||
|
if (editingFraisId) await api.put(`/frais-operations/${editingFraisId}`, payload);
|
||||||
|
else await api.post('/frais-operations', payload);
|
||||||
|
closeFraisModal();
|
||||||
|
await refreshFraisOperations();
|
||||||
|
} catch (e) { setFraisErr(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFraisDelete = (f) => {
|
||||||
|
setRowDeleteConfirm({
|
||||||
|
message: `Supprimer ce frais de ${fmtEUR(f.montant)} (${fmtDate(f.date_operation)}) ?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await api.del(`/frais-operations/${f.id}`);
|
||||||
|
setRowDeleteConfirm(null);
|
||||||
|
await refreshFraisOperations();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDocDeleteAll = () => {
|
const handleDocDeleteAll = () => {
|
||||||
setDocsMenu(null);
|
setDocsMenu(null);
|
||||||
setRowDeleteConfirm({
|
setRowDeleteConfirm({
|
||||||
@@ -353,8 +483,10 @@ export default function InvestissementDetail() {
|
|||||||
const year = parseInt(dateStr.slice(0, 4), 10);
|
const year = parseInt(dateStr.slice(0, 4), 10);
|
||||||
return pfuRates.find(r => r.annee === year) ?? getLastKnownRates();
|
return pfuRates.find(r => r.annee === year) ?? getLastKnownRates();
|
||||||
};
|
};
|
||||||
const computeInteretsNets = (f) =>
|
const computeInteretsNets = (f) => {
|
||||||
round2((Number(f.interets_bruts) || 0) - (Number(f.prelev_sociaux) || 0) - (Number(f.prelev_forfaitaire) || 0));
|
const fraisReduit = (!f.frais_mode_reglement || f.frais_mode_reglement === 'remboursement') ? (Number(f.frais_ttc) || 0) : 0;
|
||||||
|
return round2((Number(f.interets_bruts) || 0) - fraisReduit - (Number(f.prelev_sociaux) || 0) - (Number(f.prelev_forfaitaire) || 0));
|
||||||
|
};
|
||||||
const computeNet = (f) =>
|
const computeNet = (f) =>
|
||||||
round2((Number(f.capital) || 0) + (Number(f.cashback) || 0) + computeInteretsNets(f));
|
round2((Number(f.capital) || 0) + (Number(f.cashback) || 0) + computeInteretsNets(f));
|
||||||
|
|
||||||
@@ -375,9 +507,15 @@ export default function InvestissementDetail() {
|
|||||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
|
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
|
||||||
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
||||||
const bruts = Number(next.interets_bruts) || 0;
|
// Base imposable nette de frais (Étape 4, 31/08/26) — même formule que computeChamps()
|
||||||
|
// côté backend et que POST /reprocess : les prélèvements doivent porter sur
|
||||||
|
// (interets_bruts - frais_ttc), pas sur le brut seul, sous peine de sur-taxer le coupon
|
||||||
|
// (confirmé par Olivier sur l'avis d'opéré Kyoseil-AM/Fundora du 02/07/2026). Depuis
|
||||||
|
// l'Étape 5 bis (01/09/26), uniquement si le frais est retenu SUR ce remboursement.
|
||||||
|
const fraisReduit = (!next.frais_mode_reglement || next.frais_mode_reglement === 'remboursement') ? (Number(next.frais_ttc) || 0) : 0;
|
||||||
|
const bruts = round2((Number(next.interets_bruts) || 0) - fraisReduit);
|
||||||
const rates = getRatesForYear(dateStr);
|
const rates = getRatesForYear(dateStr);
|
||||||
if (rates) {
|
if (rates) {
|
||||||
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
||||||
@@ -407,6 +545,8 @@ export default function InvestissementDetail() {
|
|||||||
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
||||||
taxe_locale,
|
taxe_locale,
|
||||||
interets_bruts: brutsApresLocal,
|
interets_bruts: brutsApresLocal,
|
||||||
|
frais_mode_reglement: 'remboursement',
|
||||||
|
frais_compte_id: '',
|
||||||
prelev_sociaux: rates ? round2(baseRates * rates.prelev_sociaux / 100) : 0,
|
prelev_sociaux: rates ? round2(baseRates * rates.prelev_sociaux / 100) : 0,
|
||||||
prelev_forfaitaire: rates ? round2(baseRates * rates.impot_revenu / 100) : 0,
|
prelev_forfaitaire: rates ? round2(baseRates * rates.impot_revenu / 100) : 0,
|
||||||
statut: 'paye',
|
statut: 'paye',
|
||||||
@@ -420,6 +560,10 @@ export default function InvestissementDetail() {
|
|||||||
|
|
||||||
const openEditRemb = (r) => {
|
const openEditRemb = (r) => {
|
||||||
setEditingRembId(r.id);
|
setEditingRembId(r.id);
|
||||||
|
// Étape 5 bis (01/09/26) : le mode de règlement du frais n'est pas stocké sur le
|
||||||
|
// remboursement lui-même mais sur la ligne frais_operations liée — recherchée dans
|
||||||
|
// fraisOperations (déjà chargé pour le bloc 2b), par remboursement_id.
|
||||||
|
const linkedFrais = fraisOperations.find(f => f.remboursement_id === r.id);
|
||||||
setRembForm({
|
setRembForm({
|
||||||
date_remb: r.date_remb,
|
date_remb: r.date_remb,
|
||||||
capital: r.capital ?? 0,
|
capital: r.capital ?? 0,
|
||||||
@@ -427,6 +571,9 @@ export default function InvestissementDetail() {
|
|||||||
interets_bruts_avant_local: r.interets_bruts_avant_local ?? 0,
|
interets_bruts_avant_local: r.interets_bruts_avant_local ?? 0,
|
||||||
taxe_locale: r.taxe_locale ?? 0,
|
taxe_locale: r.taxe_locale ?? 0,
|
||||||
interets_bruts: r.interets_bruts ?? 0,
|
interets_bruts: r.interets_bruts ?? 0,
|
||||||
|
frais_ttc: r.frais_ttc ?? 0,
|
||||||
|
frais_mode_reglement: linkedFrais?.mode_reglement || 'remboursement',
|
||||||
|
frais_compte_id: String(linkedFrais?.compte_id || ''),
|
||||||
prelev_sociaux: r.prelev_sociaux ?? 0,
|
prelev_sociaux: r.prelev_sociaux ?? 0,
|
||||||
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
|
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
|
||||||
statut: r.statut,
|
statut: r.statut,
|
||||||
@@ -451,6 +598,9 @@ export default function InvestissementDetail() {
|
|||||||
interets_bruts_avant_local: 0,
|
interets_bruts_avant_local: 0,
|
||||||
taxe_locale: 0,
|
taxe_locale: 0,
|
||||||
interets_bruts: 0,
|
interets_bruts: 0,
|
||||||
|
frais_ttc: 0,
|
||||||
|
frais_mode_reglement: 'remboursement',
|
||||||
|
frais_compte_id: '',
|
||||||
prelev_sociaux: 0,
|
prelev_sociaux: 0,
|
||||||
prelev_forfaitaire: 0,
|
prelev_forfaitaire: 0,
|
||||||
statut: 'paye',
|
statut: 'paye',
|
||||||
@@ -599,6 +749,9 @@ export default function InvestissementDetail() {
|
|||||||
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
|
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
|
||||||
taxe_locale: Number(rembForm.taxe_locale || 0),
|
taxe_locale: Number(rembForm.taxe_locale || 0),
|
||||||
interets_bruts: Number(rembForm.interets_bruts || 0),
|
interets_bruts: Number(rembForm.interets_bruts || 0),
|
||||||
|
frais_ttc: Number(rembForm.frais_ttc || 0),
|
||||||
|
frais_mode_reglement: rembForm.frais_mode_reglement || 'remboursement',
|
||||||
|
frais_compte_id: rembForm.frais_mode_reglement === 'compte_courant' && rembForm.frais_compte_id ? Number(rembForm.frais_compte_id) : null,
|
||||||
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
|
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
|
||||||
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
|
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
|
||||||
statut: rembForm.statut,
|
statut: rembForm.statut,
|
||||||
@@ -624,7 +777,7 @@ export default function InvestissementDetail() {
|
|||||||
} catch (e) { setConfirmingRembDelete(false); setRembErr(e.message); }
|
} catch (e) { setConfirmingRembDelete(false); setRembErr(e.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = () => {
|
const openEdit = async () => {
|
||||||
const plat = plats.find(p => p.id === inv.plateforme_id);
|
const plat = plats.find(p => p.id === inv.plateforme_id);
|
||||||
const platMethode = plat?.methode_remboursement;
|
const platMethode = plat?.methode_remboursement;
|
||||||
const resolvedMethode = platMethode && platMethode !== 'choix_investisseur'
|
const resolvedMethode = platMethode && platMethode !== 'choix_investisseur'
|
||||||
@@ -654,10 +807,31 @@ export default function InvestissementDetail() {
|
|||||||
pays_exposition: inv.pays_exposition || 'FR',
|
pays_exposition: inv.pays_exposition || 'FR',
|
||||||
categories_inv_ids: (inv.categories_inv || []).map(c => c.id),
|
categories_inv_ids: (inv.categories_inv || []).map(c => c.id),
|
||||||
secteurs_inv_ids: (inv.secteurs_inv || []).map(s => s.id),
|
secteurs_inv_ids: (inv.secteurs_inv || []).map(s => s.id),
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||||
});
|
});
|
||||||
lastValidSouscriptionRef.current = inv.date_souscription || '';
|
lastValidSouscriptionRef.current = inv.date_souscription || '';
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
|
|
||||||
|
// Pré-remplissage de l'étape "Frais" avec le frais de souscription déjà documenté pour cet
|
||||||
|
// investissement, s'il existe — même logique que Investissements.jsx (cf.
|
||||||
|
// project_frais_operation.md, catégorie 'souscription' unique par investissement).
|
||||||
|
try {
|
||||||
|
const fraisOps = await api.get('/frais-operations', { investissement_id: inv.id });
|
||||||
|
const souscriptionFrais = (fraisOps || []).find(f => f.categorie === 'souscription');
|
||||||
|
if (souscriptionFrais) {
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
frais_mode_saisie: 'montant',
|
||||||
|
frais_valeur: souscriptionFrais.montant,
|
||||||
|
frais_mode_reglement: souscriptionFrais.mode_reglement,
|
||||||
|
frais_compte_id: souscriptionFrais.compte_id || '',
|
||||||
|
frais_id: souscriptionFrais.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Pré-remplissage best-effort — une erreur réseau ne doit pas empêcher l'édition.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement héritée
|
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement héritée
|
||||||
@@ -678,10 +852,15 @@ export default function InvestissementDetail() {
|
|||||||
methode_remboursement: newMethode, compte_id: '',
|
methode_remboursement: newMethode, compte_id: '',
|
||||||
categories_inv_ids: platCats.map(c => c.id),
|
categories_inv_ids: platCats.map(c => c.id),
|
||||||
secteurs_inv_ids: platSects.map(s => s.id),
|
secteurs_inv_ids: platSects.map(s => s.id),
|
||||||
|
// Réinitialise la saisie de l'étape "Frais" (le référentiel de frais dépend de la
|
||||||
|
// plateforme) — frais_id (l'entrée existante éventuelle) n'est PAS réinitialisé : il
|
||||||
|
// reste lié à cet investissement, pas à la plateforme sélectionnée dans le formulaire.
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '' }));
|
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '',
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -703,6 +882,20 @@ export default function InvestissementDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frais liés à la plateforme (étape conditionnelle du formulaire, cf.
|
||||||
|
// project_frais_operation.md) : montant_investi n'est JAMAIS réajusté automatiquement
|
||||||
|
// ici (édition uniquement sur cette page — seule la création, dans Investissements.jsx,
|
||||||
|
// ajuste le capital net) ; le frais est journalisé à part, en upsert (POST/PUT/DELETE
|
||||||
|
// selon form.frais_id) après l'enregistrement principal.
|
||||||
|
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||||
|
const hasFrais = platFraisType && platFraisType !== 'aucun'
|
||||||
|
&& form.frais_valeur !== '' && Number(form.frais_valeur) > 0;
|
||||||
|
const fraisTTC = hasFrais
|
||||||
|
? Math.round((form.frais_mode_saisie === 'pourcentage'
|
||||||
|
? Number(form.montant_investi) * Number(form.frais_valeur) / 100
|
||||||
|
: Number(form.frais_valeur)) * 100) / 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||||
plateforme_id: Number(form.plateforme_id),
|
plateforme_id: Number(form.plateforme_id),
|
||||||
@@ -731,8 +924,32 @@ export default function InvestissementDetail() {
|
|||||||
api.put(`/investissements/${id}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
api.put(`/investissements/${id}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
||||||
api.put(`/investissements/${id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
api.put(`/investissements/${id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Upsert du frais de souscription (catégorie unique par investissement) — même logique
|
||||||
|
// que Investissements.jsx (cf. project_frais_operation.md).
|
||||||
|
try {
|
||||||
|
if (hasFrais) {
|
||||||
|
const fraisPayload = {
|
||||||
|
investissement_id: Number(id),
|
||||||
|
montant: fraisTTC,
|
||||||
|
mode_reglement: form.frais_mode_reglement,
|
||||||
|
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||||
|
date_operation: form.date_souscription,
|
||||||
|
origine: 'souscription',
|
||||||
|
categorie: 'souscription',
|
||||||
|
};
|
||||||
|
if (form.frais_id) await api.put(`/frais-operations/${form.frais_id}`, fraisPayload);
|
||||||
|
else await api.post('/frais-operations', fraisPayload);
|
||||||
|
} else if (form.frais_id) {
|
||||||
|
await api.del(`/frais-operations/${form.frais_id}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non bloquant — rattrapable a posteriori depuis le bloc "Frais d'opération".
|
||||||
|
}
|
||||||
|
|
||||||
close();
|
close();
|
||||||
await load();
|
await load();
|
||||||
|
await refreshFraisOperations();
|
||||||
} catch (e) { setErr(e.message); }
|
} catch (e) { setErr(e.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -886,6 +1103,12 @@ export default function InvestissementDetail() {
|
|||||||
const autoReinvActive = !!inv?.auto_reinvest;
|
const autoReinvActive = !!inv?.auto_reinvest;
|
||||||
const hasReinvests = reinvs.length > 0 || autoReinvActive;
|
const hasReinvests = reinvs.length > 0 || autoReinvActive;
|
||||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||||
|
// Bloc "Frais d'opération" : visible si la plateforme facture des frais (référentiel
|
||||||
|
// frais_plateforme ≠ 'aucun'), ou s'il existe déjà des frais journalisés (garde l'accès même
|
||||||
|
// si la config de la plateforme change ensuite) — cf. project_frais_operation.md.
|
||||||
|
const platFraisType = plats.find(p => p.id === inv?.plateforme_id)?.frais_plateforme;
|
||||||
|
const showFraisCard = (platFraisType && platFraisType !== 'aucun') || fraisOperations.length > 0;
|
||||||
|
const totalFrais = fraisOperations.reduce((sum, f) => sum + (f.montant || 0), 0);
|
||||||
// Calculés depuis les remboursements chargés (la route /:id ne retourne pas ces agrégats)
|
// Calculés depuis les remboursements chargés (la route /:id ne retourne pas ces agrégats)
|
||||||
const capitalRembourse = remb.reduce((s, r) => s + (r.capital || 0), 0);
|
const capitalRembourse = remb.reduce((s, r) => s + (r.capital || 0), 0);
|
||||||
// Capital déclaré en perte définitive (0 si non déclaré) — déduit du capital restant dû
|
// Capital déclaré en perte définitive (0 si non déclaré) — déduit du capital restant dû
|
||||||
@@ -1036,6 +1259,13 @@ export default function InvestissementDetail() {
|
|||||||
const reinvOutflows = reinvs.map(r => ({ amount: -r.montant, date: r.date_reinvestissement }));
|
const reinvOutflows = reinvs.map(r => ({ amount: -r.montant, date: r.date_reinvestissement }));
|
||||||
// Valorisation à date du capital pas encore remboursé (uniquement pour l'estimation en cours)
|
// Valorisation à date du capital pas encore remboursé (uniquement pour l'estimation en cours)
|
||||||
const valorisationAuj = rendementEstime && capRestant > 0 ? [{ amount: capRestant, date: todayStr }] : [];
|
const valorisationAuj = rendementEstime && capRestant > 0 ? [{ amount: capRestant, date: todayStr }] : [];
|
||||||
|
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
|
||||||
|
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
|
||||||
|
// ou 'compte_courant' — vraie sortie d'argent, à compter comme un flux négatif
|
||||||
|
// dans le XIRR NET uniquement (le brut reste "avant tout frais").
|
||||||
|
const fraisOutflows = fraisOperations
|
||||||
|
.filter(f => f.mode_reglement === 'portefeuille' || f.mode_reglement === 'compte_courant')
|
||||||
|
.map(f => ({ amount: -(f.montant || 0), date: f.date_operation }));
|
||||||
// Brut : capital + cashback + intérêts bruts (avant fiscalité)
|
// Brut : capital + cashback + intérêts bruts (avant fiscalité)
|
||||||
const cashflowsBrut = [
|
const cashflowsBrut = [
|
||||||
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
||||||
@@ -1050,6 +1280,7 @@ export default function InvestissementDetail() {
|
|||||||
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
||||||
...reinvOutflows,
|
...reinvOutflows,
|
||||||
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
|
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
|
||||||
|
...fraisOutflows,
|
||||||
...valorisationAuj,
|
...valorisationAuj,
|
||||||
];
|
];
|
||||||
rendementReel = xirr(cashflows);
|
rendementReel = xirr(cashflows);
|
||||||
@@ -1068,6 +1299,7 @@ export default function InvestissementDetail() {
|
|||||||
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
{ amount: -inv.montant_investi, date: inv.date_souscription },
|
||||||
...reinvOutflows,
|
...reinvOutflows,
|
||||||
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
|
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
|
||||||
|
...fraisOutflows,
|
||||||
];
|
];
|
||||||
for (const s of futureEcheances) {
|
for (const s of futureEcheances) {
|
||||||
const rates = getRatesForYear(s.date_prevue);
|
const rates = getRatesForYear(s.date_prevue);
|
||||||
@@ -1159,10 +1391,11 @@ export default function InvestissementDetail() {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const rendement = netMode ? rendementReel : rendementReelBrut;
|
const rendement = netMode ? rendementReel : rendementReelBrut;
|
||||||
const rendementAttenduAffiche = netMode ? rendementAttendu : rendementAttenduBrut;
|
const rendementAttenduAffiche = netMode ? rendementAttendu : rendementAttenduBrut;
|
||||||
|
const fraisExplication = ` Les frais réglés hors remboursement (via le porte-monnaie ou un compte courant) sont eux aussi comptés comme une sortie d'argent dans ce calcul net — en plus des frais déjà déduits à la source ou d'un remboursement, toujours pris en compte.`;
|
||||||
const xirrExplication =
|
const xirrExplication =
|
||||||
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir des flux réels datés ` +
|
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir des flux réels datés ` +
|
||||||
`(versement initial, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
|
`(versement initial, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
|
||||||
`Chaque flux est pondéré par sa date exacte — deux prêts au même total mais versés à des rythmes différents auront un XIRR différent.\n\n` +
|
`Chaque flux est pondéré par sa date exacte — deux prêts au même total mais versés à des rythmes différents auront un XIRR différent.` + (netMode ? fraisExplication : '') + `\n\n` +
|
||||||
(rendementEstime
|
(rendementEstime
|
||||||
? "Prêt pas encore soldé : le capital restant dû est ajouté en flux final, valorisé à la date du jour — c'est donc une estimation, pas un taux définitif."
|
? "Prêt pas encore soldé : le capital restant dû est ajouté en flux final, valorisé à la date du jour — c'est donc une estimation, pas un taux définitif."
|
||||||
: 'Prêt intégralement remboursé : taux définitif.');
|
: 'Prêt intégralement remboursé : taux définitif.');
|
||||||
@@ -1171,7 +1404,7 @@ export default function InvestissementDetail() {
|
|||||||
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
|
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
|
||||||
`que toutes les échéances prévues (capital + intérêts) encore à venir seront honorées exactement à la date prévue ` +
|
`que toutes les échéances prévues (capital + intérêts) encore à venir seront honorées exactement à la date prévue ` +
|
||||||
`par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû "comme racheté ` +
|
`par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû "comme racheté ` +
|
||||||
`aujourd'hui", sans compter les intérêts futurs.\n\n` +
|
`aujourd'hui", sans compter les intérêts futurs.` + (netMode ? fraisExplication : '') + `\n\n` +
|
||||||
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement. Si ce ` +
|
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement. Si ce ` +
|
||||||
`prêt n'a pas d'échéancier prévisionnel en base, il est valorisé de la même façon que dans le XIRR réel.`;
|
`prêt n'a pas d'échéancier prévisionnel en base, il est valorisé de la même façon que dans le XIRR réel.`;
|
||||||
return (
|
return (
|
||||||
@@ -1552,6 +1785,102 @@ export default function InvestissementDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Frais d'opération ──────────────────────────────────────── */}
|
||||||
|
{showFraisCard && (
|
||||||
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>
|
||||||
|
Frais d'opération
|
||||||
|
{fraisOperations.length > 0 && (
|
||||||
|
<span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
|
||||||
|
Total : {fmtEUR(totalFrais)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setFraisMenu({ x: r.right, y: r.bottom }); }}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
|
||||||
|
title="Actions"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fraisOperations.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
|
||||||
|
Aucun frais journalisé pour l'instant. Utilisez le menu <strong>⋮</strong> ci-dessus pour en ajouter un.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{fraisOperations.map(f => {
|
||||||
|
// Catégorie 'souscription' : unique par investissement, modifiable uniquement
|
||||||
|
// depuis l'étape "Frais" de "Modifier l'investissement" (décision Olivier,
|
||||||
|
// 31/08/26) — le clic et l'icône Modifier redirigent vers ce formulaire plutôt
|
||||||
|
// que d'ouvrir la modale légère de ce bloc.
|
||||||
|
const isSouscription = f.categorie === 'souscription';
|
||||||
|
// mode_reglement 'remboursement' (Étape 4, 31/08/26) : ligne générée/synchronisée
|
||||||
|
// automatiquement depuis le champ "Frais" d'un remboursement (frais_ttc) — montant
|
||||||
|
// déjà pris en compte dans le calcul fiscal du remboursement, donc jamais éditable
|
||||||
|
// ni supprimable directement ici (désynchroniserait les deux). Redirige vers le
|
||||||
|
// remboursement source, comme pour la catégorie 'souscription' vers le formulaire.
|
||||||
|
// Étape 5 bis (01/09/26) : "lié à un remboursement" ne veut plus dire
|
||||||
|
// mode_reglement === 'remboursement' — le frais peut être réglé à part
|
||||||
|
// (portefeuille/compte courant) tout en restant géré depuis le remboursement.
|
||||||
|
const isRembLinked = f.remboursement_id != null;
|
||||||
|
const linkedRemb = isRembLinked ? remb.find(r => r.id === f.remboursement_id) : null;
|
||||||
|
const goToLinkedRemb = () => { if (linkedRemb) openEditRemb(linkedRemb); };
|
||||||
|
const isManaged = isSouscription || isRembLinked;
|
||||||
|
const manageAction = isSouscription ? openEdit : goToLinkedRemb;
|
||||||
|
const manageTitle = isSouscription
|
||||||
|
? "Modifiable depuis « Modifier l'investissement »"
|
||||||
|
: isRembLinked
|
||||||
|
? 'Modifiable depuis le remboursement lié'
|
||||||
|
: 'Cliquer pour modifier';
|
||||||
|
return (
|
||||||
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||||
|
<PageIcon name="frais" size={45} style={{ marginRight: 0, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}
|
||||||
|
onClick={() => isManaged ? manageAction() : openEditFraisModal(f)}
|
||||||
|
title={isManaged ? manageTitle : 'Cliquer pour modifier'}>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)' }}>
|
||||||
|
{fmtEUR(f.montant)}
|
||||||
|
<span className="text-muted" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||||
|
{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}
|
||||||
|
{f.mode_reglement === 'compte_courant' && f.compte_nom ? ` (${f.compte_nom})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{fmtDate(f.date_operation)} · {FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}
|
||||||
|
{f.notes ? ` · ${f.notes}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
title={isManaged ? manageTitle : 'Modifier'}
|
||||||
|
onClick={() => isManaged ? manageAction() : openEditFraisModal(f)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, padding: 0, background: 'none', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
{isRembLinked ? (
|
||||||
|
<span title="Géré depuis le remboursement — modifiez ou videz le champ « Frais » du remboursement pour le retirer"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, flexShrink: 0, color: 'var(--text-muted)' }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button type="button" title="Supprimer" onClick={() => handleFraisDelete(f)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, padding: 0, background: 'none', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--danger)', flexShrink: 0 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Remboursements enregistrés ────────────────────────────── */}
|
{/* ── Remboursements enregistrés ────────────────────────────── */}
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
@@ -1621,17 +1950,46 @@ export default function InvestissementDetail() {
|
|||||||
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
|
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
|
||||||
<td className="num">{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}</td>
|
<td className="num">{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}</td>
|
||||||
)}
|
)}
|
||||||
<td
|
{(() => {
|
||||||
className={tauxImpliciteApplicable ? 'num cell-tooltip' : 'num'}
|
// Icône uniquement si le frais réduit réellement ce montant (mode
|
||||||
data-tooltip={(() => {
|
// 'remboursement', par défaut) — cf. RembFormModal.jsx, Étape 5 bis.
|
||||||
if (!tauxImpliciteApplicable) return undefined;
|
const showFraisIcon = !netMode && !!r.frais_ttc && (!r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement');
|
||||||
|
const tooltipParts = [];
|
||||||
|
if (tauxImpliciteApplicable) {
|
||||||
const ti = tauxImpliciteByRembId.get(r.id);
|
const ti = tauxImpliciteByRembId.get(r.id);
|
||||||
if (!ti || !(ti.base > 0)) return 'Taux implicite non calculable (capital restant nul ou inconnu)';
|
tooltipParts.push(!ti || !(ti.base > 0)
|
||||||
return `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`;
|
? 'Taux implicite non calculable (capital restant nul ou inconnu)'
|
||||||
})()}
|
: `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`);
|
||||||
>
|
}
|
||||||
{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}
|
if (showFraisIcon) {
|
||||||
</td>
|
tooltipParts.push(
|
||||||
|
`Intérêts bruts avant frais : ${fmtEUR(r.interets_bruts)}\n` +
|
||||||
|
`Frais de distribution : -${fmtEUR(r.frais_ttc)}\n` +
|
||||||
|
`Base après frais (avant prélèvements) : ${fmtEUR(round2(r.interets_bruts - r.frais_ttc))}\n` +
|
||||||
|
`Prélèv. sociaux : ${fmtEUR(r.prelev_sociaux)}\n` +
|
||||||
|
`Impôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}\n` +
|
||||||
|
`Montant net reçu : ${fmtEUR(r.net_recu)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tooltip = tooltipParts.length ? tooltipParts.join('\n\n') : undefined;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={tooltip ? 'num cell-tooltip' : 'num'}
|
||||||
|
data-tooltip={tooltip}
|
||||||
|
>
|
||||||
|
{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}
|
||||||
|
{showFraisIcon && (
|
||||||
|
<span style={{ marginLeft: 5, color: 'var(--warning, #b45309)', verticalAlign: 'middle' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<td className="num">{fmtEUR(r.cashback)}</td>
|
<td className="num">{fmtEUR(r.cashback)}</td>
|
||||||
{showImpositionDetail && <td className="num">{fmtEUR(r.prelev_forfaitaire)}</td>}
|
{showImpositionDetail && <td className="num">{fmtEUR(r.prelev_forfaitaire)}</td>}
|
||||||
{showImpositionDetail && <td className="num">{fmtEUR(r.prelev_sociaux)}</td>}
|
{showImpositionDetail && <td className="num">{fmtEUR(r.prelev_sociaux)}</td>}
|
||||||
@@ -2879,6 +3237,98 @@ export default function InvestissementDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Menu ⋮ bloc "Frais d'opération" ── */}
|
||||||
|
{fraisMenu && (
|
||||||
|
<>
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setFraisMenu(null)} />
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', left: fraisMenu.x, top: fraisMenu.y,
|
||||||
|
transform: 'translateX(-100%) translateY(4px)',
|
||||||
|
zIndex: 300,
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||||
|
padding: '4px 0', minWidth: 200,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
onClick={() => { setFraisMenu(null); openFraisModal(); }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Ajouter un frais
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Modale "Frais d'opération" (ajout / édition) ─────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={fraisModalOpen}
|
||||||
|
title={editingFraisId ? 'Modifier le frais' : 'Ajouter un frais'}
|
||||||
|
onClose={closeFraisModal}
|
||||||
|
width={480}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="ghost" onClick={closeFraisModal}>Annuler</button>
|
||||||
|
<button className="primary" onClick={submitFrais}>{editingFraisId ? 'Enregistrer' : 'Ajouter'}</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={submitFrais}>
|
||||||
|
{fraisErr && <div className="error">{fraisErr}</div>}
|
||||||
|
<div className="form-grid">
|
||||||
|
<div>
|
||||||
|
<label>Montant TTC (€) *</label>
|
||||||
|
<input type="number" step="0.01" min="0" required value={fraisForm.montant}
|
||||||
|
onChange={e => setFraisForm(f => ({ ...f, montant: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Date de l'opération *</label>
|
||||||
|
<input type="date" required value={fraisForm.date_operation}
|
||||||
|
onChange={e => setFraisForm(f => ({ ...f, date_operation: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label>Catégorie</label>
|
||||||
|
<select value={fraisForm.categorie} onChange={e => setFraisForm(f => ({ ...f, categorie: e.target.value }))}>
|
||||||
|
<option value="gestion">Gestion</option>
|
||||||
|
<option value="distribution">Distribution</option>
|
||||||
|
<option value="autre">Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label>Prélèvement</label>
|
||||||
|
<select value={fraisForm.mode_reglement}
|
||||||
|
onChange={e => setFraisForm(f => ({ ...f, mode_reglement: e.target.value, compte_id: e.target.value === 'compte_courant' ? f.compte_id : '' }))}>
|
||||||
|
<option value="source">À la source (sans mouvement complémentaire)</option>
|
||||||
|
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
|
||||||
|
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{fraisForm.mode_reglement === 'compte_courant' && (
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label>Compte de règlement *</label>
|
||||||
|
{comptesFraisInvestisseur.length > 0 ? (
|
||||||
|
<select required value={fraisForm.compte_id} onChange={e => setFraisForm(f => ({ ...f, compte_id: e.target.value }))}>
|
||||||
|
<option value="">— Choisir un compte —</option>
|
||||||
|
{comptesFraisInvestisseur.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||||
|
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label>Notes</label>
|
||||||
|
<input value={fraisForm.notes} onChange={e => setFraisForm(f => ({ ...f, notes: e.target.value }))} placeholder="Précisions optionnelles…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={!!rowDeleteConfirm}
|
open={!!rowDeleteConfirm}
|
||||||
message={rowDeleteConfirm?.message}
|
message={rowDeleteConfirm?.message}
|
||||||
|
|||||||
@@ -239,6 +239,11 @@ const empty = {
|
|||||||
statut: 'en_cours', reference: '', notes: '',
|
statut: 'en_cours', reference: '', notes: '',
|
||||||
echeance_fin_de_mois: false, methode_remboursement: '', nom_compte_courant: '', compte_id: '', pays_exposition: 'FR',
|
echeance_fin_de_mois: false, methode_remboursement: '', nom_compte_courant: '', compte_id: '', pays_exposition: 'FR',
|
||||||
categories_inv_ids: [], secteurs_inv_ids: [],
|
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||||
|
// Frais liés à la plateforme (étape conditionnelle, visible création ET modification —
|
||||||
|
// cf. project_frais_operation.md). frais_id : id de l'entrée frais_operations de catégorie
|
||||||
|
// 'souscription' déjà existante pour cet investissement (pré-remplie par openEdit), null si
|
||||||
|
// aucune — pilote l'upsert (POST/PUT/DELETE) au submit, jamais affiché dans le formulaire.
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Composant principal ─────────────────────────────────────── */
|
/* ── Composant principal ─────────────────────────────────────── */
|
||||||
@@ -702,9 +707,31 @@ export default function Investissements() {
|
|||||||
pays_exposition: row.pays_exposition || 'FR',
|
pays_exposition: row.pays_exposition || 'FR',
|
||||||
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
|
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
|
||||||
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
|
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
|
||||||
});
|
});
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
|
|
||||||
|
// Pré-remplissage de l'étape "Frais" avec le frais de souscription déjà documenté pour cet
|
||||||
|
// investissement, s'il existe (catégorie 'souscription', unique par investissement — cf.
|
||||||
|
// project_frais_operation.md). Chargé après l'ouverture de la modale pour ne pas bloquer
|
||||||
|
// l'affichage ; mise à jour du form dès la réponse.
|
||||||
|
try {
|
||||||
|
const fraisOps = await api.get('/frais-operations', { investissement_id: row.id });
|
||||||
|
const souscriptionFrais = (fraisOps || []).find(f => f.categorie === 'souscription');
|
||||||
|
if (souscriptionFrais) {
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
frais_mode_saisie: 'montant',
|
||||||
|
frais_valeur: souscriptionFrais.montant,
|
||||||
|
frais_mode_reglement: souscriptionFrais.mode_reglement,
|
||||||
|
frais_compte_id: souscriptionFrais.compte_id || '',
|
||||||
|
frais_id: souscriptionFrais.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Pré-remplissage best-effort — une erreur réseau ne doit pas empêcher l'édition.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement,
|
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement,
|
||||||
@@ -724,10 +751,12 @@ export default function Investissements() {
|
|||||||
methode_remboursement: newMethode, compte_id: '',
|
methode_remboursement: newMethode, compte_id: '',
|
||||||
categories_inv_ids: platCats.map(c => c.id),
|
categories_inv_ids: platCats.map(c => c.id),
|
||||||
secteurs_inv_ids: platSects.map(s => s.id),
|
secteurs_inv_ids: platSects.map(s => s.id),
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
|
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [],
|
||||||
|
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -751,6 +780,26 @@ export default function Investissements() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frais liés à la plateforme (étape conditionnelle, création ET modification — le bloc
|
||||||
|
// "Frais d'opération" de la fiche investissement reste le lieu pour les frais de
|
||||||
|
// catégorie gestion/distribution/autre, cf. project_frais_operation.md) : à la création,
|
||||||
|
// si prélevés "à la source", le capital net (montant investi diminué des frais) est ce
|
||||||
|
// qui est enregistré comme montant_investi ; en modification, montant_investi n'est
|
||||||
|
// JAMAIS réajusté automatiquement (demande explicite d'Olivier, 31/08/26) — c'est à
|
||||||
|
// l'utilisateur de l'ajuster lui-même à l'étape 1 si besoin. Dans les deux cas, le frais
|
||||||
|
// est journalisé (POST/PUT/DELETE selon form.frais_id) après l'enregistrement principal.
|
||||||
|
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||||
|
const hasFrais = platFraisType && platFraisType !== 'aucun'
|
||||||
|
&& form.frais_valeur !== '' && Number(form.frais_valeur) > 0;
|
||||||
|
const fraisTTC = hasFrais
|
||||||
|
? Math.round((form.frais_mode_saisie === 'pourcentage'
|
||||||
|
? Number(form.montant_investi) * Number(form.frais_valeur) / 100
|
||||||
|
: Number(form.frais_valeur)) * 100) / 100
|
||||||
|
: 0;
|
||||||
|
const montantInvestiFinal = !editingId && hasFrais && form.frais_mode_reglement === 'source'
|
||||||
|
? Math.max(0, Math.round((Number(form.montant_investi) - fraisTTC) * 100) / 100)
|
||||||
|
: Number(form.montant_investi);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||||
plateforme_id: Number(form.plateforme_id),
|
plateforme_id: Number(form.plateforme_id),
|
||||||
@@ -759,7 +808,7 @@ export default function Investissements() {
|
|||||||
date_souscription: form.date_souscription,
|
date_souscription: form.date_souscription,
|
||||||
date_premiere_echeance: form.date_premiere_echeance || '',
|
date_premiere_echeance: form.date_premiere_echeance || '',
|
||||||
date_cible: form.date_cible || '',
|
date_cible: form.date_cible || '',
|
||||||
montant_investi: Number(form.montant_investi),
|
montant_investi: montantInvestiFinal,
|
||||||
taux_interet: form.taux_interet === '' ? undefined : Number(form.taux_interet),
|
taux_interet: form.taux_interet === '' ? undefined : Number(form.taux_interet),
|
||||||
duree_mois: form.duree_mois === '' ? undefined : Number(form.duree_mois),
|
duree_mois: form.duree_mois === '' ? undefined : Number(form.duree_mois),
|
||||||
type_remb: form.type_remb || '',
|
type_remb: form.type_remb || '',
|
||||||
@@ -779,6 +828,31 @@ export default function Investissements() {
|
|||||||
api.put(`/investissements/${editingId}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
api.put(`/investissements/${editingId}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
||||||
api.put(`/investissements/${editingId}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
api.put(`/investissements/${editingId}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Upsert du frais de souscription (catégorie unique par investissement) : PUT si déjà
|
||||||
|
// chargé via form.frais_id (pré-rempli par openEdit), POST sinon ; DELETE si le montant
|
||||||
|
// a été vidé alors qu'une entrée existait — best-effort, n'empêche jamais l'édition
|
||||||
|
// principale de l'investissement.
|
||||||
|
try {
|
||||||
|
if (hasFrais) {
|
||||||
|
const fraisPayload = {
|
||||||
|
investissement_id: editingId,
|
||||||
|
montant: fraisTTC,
|
||||||
|
mode_reglement: form.frais_mode_reglement,
|
||||||
|
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||||
|
date_operation: form.date_souscription,
|
||||||
|
origine: 'souscription',
|
||||||
|
categorie: 'souscription',
|
||||||
|
};
|
||||||
|
if (form.frais_id) await api.put(`/frais-operations/${form.frais_id}`, fraisPayload);
|
||||||
|
else await api.post('/frais-operations', fraisPayload);
|
||||||
|
} else if (form.frais_id) {
|
||||||
|
await api.del(`/frais-operations/${form.frais_id}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non bloquant — rattrapable a posteriori depuis le bloc "Frais d'opération".
|
||||||
|
}
|
||||||
|
|
||||||
close(); await load();
|
close(); await load();
|
||||||
} else {
|
} else {
|
||||||
const created = await api.post('/investissements', payload);
|
const created = await api.post('/investissements', payload);
|
||||||
@@ -787,6 +861,23 @@ export default function Investissements() {
|
|||||||
api.put(`/investissements/${created.id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
api.put(`/investissements/${created.id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (hasFrais) {
|
||||||
|
try {
|
||||||
|
await api.post('/frais-operations', {
|
||||||
|
investissement_id: created.id,
|
||||||
|
montant: fraisTTC,
|
||||||
|
mode_reglement: form.frais_mode_reglement,
|
||||||
|
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||||
|
date_operation: form.date_souscription,
|
||||||
|
origine: 'souscription',
|
||||||
|
categorie: 'souscription',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Frais non journalisés (erreur réseau…) — n'empêche pas la création de l'investissement,
|
||||||
|
// qui a déjà pris en compte le capital net le cas échéant. Rattrapable a posteriori.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Contrôle du porte-monnaie — création uniquement (jamais en édition). Le solde
|
// Contrôle du porte-monnaie — création uniquement (jamais en édition). Le solde
|
||||||
// recalculé APRÈS création tient déjà compte du capital de ce nouvel investissement
|
// recalculé APRÈS création tient déjà compte du capital de ce nouvel investissement
|
||||||
// (formule officielle, cf. mémoire project_solde_portefeuille) : s'il ressort négatif,
|
// (formule officielle, cf. mémoire project_solde_portefeuille) : s'il ressort négatif,
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export default function Plateformes() {
|
|||||||
const [plats, setPlats] = useState([]);
|
const [plats, setPlats] = useState([]);
|
||||||
const [allDepots, setAllDepots] = useState([]);
|
const [allDepots, setAllDepots] = useState([]);
|
||||||
const [allCorrections, setAllCorrections] = useState([]);
|
const [allCorrections, setAllCorrections] = useState([]);
|
||||||
|
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs (filtré via mvInvById)
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
// Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31'
|
// Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31'
|
||||||
// (GET /dashboard[?asOf=]) — remplace le recalcul client historique de
|
// (GET /dashboard[?asOf=]) — remplace le recalcul client historique de
|
||||||
@@ -369,7 +370,7 @@ export default function Plateformes() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu] = await Promise.all([
|
const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu, fraisOps] = await Promise.all([
|
||||||
api.get('/investissements', scopeParams),
|
api.get('/investissements', scopeParams),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/remboursements', scopeParams),
|
api.get('/remboursements', scopeParams),
|
||||||
@@ -378,6 +379,9 @@ export default function Plateformes() {
|
|||||||
api.get('/corrections', scopeParams),
|
api.get('/corrections', scopeParams),
|
||||||
api.get('/simul/all', scopeParams),
|
api.get('/simul/all', scopeParams),
|
||||||
api.get('/pfu'),
|
api.get('/pfu'),
|
||||||
|
// /frais-operations sans investissement_id ne connaît lui aussi que l'utilisateur —
|
||||||
|
// même pattern que /reinvestissements, filtré côté client via mvInvById.
|
||||||
|
api.get('/frais-operations').catch(() => []),
|
||||||
]);
|
]);
|
||||||
setAllRows(rows);
|
setAllRows(rows);
|
||||||
setPlats(p);
|
setPlats(p);
|
||||||
@@ -387,6 +391,7 @@ export default function Plateformes() {
|
|||||||
setAllCorrections(corrections);
|
setAllCorrections(corrections);
|
||||||
setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []);
|
setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []);
|
||||||
setPfuRates(Array.isArray(pfu) ? pfu : []);
|
setPfuRates(Array.isArray(pfu) ? pfu : []);
|
||||||
|
setAllFraisOps(Array.isArray(fraisOps) ? fraisOps : []);
|
||||||
} finally { setLoading(false); setDrillRefreshKey(k => k + 1); }
|
} finally { setLoading(false); setDrillRefreshKey(k => k + 1); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -457,9 +462,15 @@ export default function Plateformes() {
|
|||||||
if (k === 'taxe_locale') {
|
if (k === 'taxe_locale') {
|
||||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
||||||
}
|
}
|
||||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
|
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
|
||||||
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
||||||
const bruts = Number(next.interets_bruts) || 0;
|
// Base imposable nette de frais (Étape 4, 31/08/26) — même formule que computeChamps()
|
||||||
|
// côté backend et que POST /reprocess : les prélèvements doivent porter sur
|
||||||
|
// (interets_bruts - frais_ttc), pas sur le brut seul, sous peine de sur-taxer le coupon
|
||||||
|
// (confirmé par Olivier sur l'avis d'opéré Kyoseil-AM/Fundora du 02/07/2026). Depuis
|
||||||
|
// l'Étape 5 bis (01/09/26), uniquement si le frais est retenu SUR ce remboursement.
|
||||||
|
const fraisReduit = (!next.frais_mode_reglement || next.frais_mode_reglement === 'remboursement') ? (Number(next.frais_ttc) || 0) : 0;
|
||||||
|
const bruts = round2((Number(next.interets_bruts) || 0) - fraisReduit);
|
||||||
const rates = getRatesForYear(dateStr);
|
const rates = getRatesForYear(dateStr);
|
||||||
if (rates) {
|
if (rates) {
|
||||||
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
||||||
@@ -474,6 +485,10 @@ export default function Plateformes() {
|
|||||||
const inv = allRows.find(i => i.id === r.investissement_id);
|
const inv = allRows.find(i => i.id === r.investissement_id);
|
||||||
setRembInv(inv || null);
|
setRembInv(inv || null);
|
||||||
setRembEditingId(r.id);
|
setRembEditingId(r.id);
|
||||||
|
// Étape 5 bis (01/09/26) : le mode de règlement du frais n'est pas stocké sur le
|
||||||
|
// remboursement lui-même mais sur la ligne frais_operations liée — recherchée dans
|
||||||
|
// allFraisOps déjà chargé, par remboursement_id.
|
||||||
|
const linkedFrais = allFraisOps.find(f => f.remboursement_id === r.id);
|
||||||
setRembForm({
|
setRembForm({
|
||||||
date_remb: r.date_remb,
|
date_remb: r.date_remb,
|
||||||
capital: r.capital ?? 0,
|
capital: r.capital ?? 0,
|
||||||
@@ -481,6 +496,9 @@ export default function Plateformes() {
|
|||||||
interets_bruts_avant_local: r.interets_bruts_avant_local ?? 0,
|
interets_bruts_avant_local: r.interets_bruts_avant_local ?? 0,
|
||||||
taxe_locale: r.taxe_locale ?? 0,
|
taxe_locale: r.taxe_locale ?? 0,
|
||||||
interets_bruts: r.interets_bruts ?? 0,
|
interets_bruts: r.interets_bruts ?? 0,
|
||||||
|
frais_ttc: r.frais_ttc ?? 0,
|
||||||
|
frais_mode_reglement: linkedFrais?.mode_reglement || 'remboursement',
|
||||||
|
frais_compte_id: String(linkedFrais?.compte_id || ''),
|
||||||
prelev_sociaux: r.prelev_sociaux ?? 0,
|
prelev_sociaux: r.prelev_sociaux ?? 0,
|
||||||
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
|
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
|
||||||
statut: r.statut,
|
statut: r.statut,
|
||||||
@@ -514,6 +532,9 @@ export default function Plateformes() {
|
|||||||
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
||||||
taxe_locale,
|
taxe_locale,
|
||||||
interets_bruts: brutsApresLocal,
|
interets_bruts: brutsApresLocal,
|
||||||
|
frais_ttc: 0,
|
||||||
|
frais_mode_reglement: 'remboursement',
|
||||||
|
frais_compte_id: '',
|
||||||
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
|
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
|
||||||
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
|
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
|
||||||
statut: 'paye',
|
statut: 'paye',
|
||||||
@@ -549,6 +570,9 @@ export default function Plateformes() {
|
|||||||
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
|
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
|
||||||
taxe_locale: Number(rembForm.taxe_locale || 0),
|
taxe_locale: Number(rembForm.taxe_locale || 0),
|
||||||
interets_bruts: Number(rembForm.interets_bruts || 0),
|
interets_bruts: Number(rembForm.interets_bruts || 0),
|
||||||
|
frais_ttc: Number(rembForm.frais_ttc || 0),
|
||||||
|
frais_mode_reglement: rembForm.frais_mode_reglement || 'remboursement',
|
||||||
|
frais_compte_id: rembForm.frais_mode_reglement === 'compte_courant' && rembForm.frais_compte_id ? Number(rembForm.frais_compte_id) : null,
|
||||||
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
|
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
|
||||||
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
|
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
|
||||||
statut: rembForm.statut,
|
statut: rembForm.statut,
|
||||||
@@ -892,6 +916,16 @@ export default function Plateformes() {
|
|||||||
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
||||||
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
||||||
}
|
}
|
||||||
|
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
|
||||||
|
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
|
||||||
|
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
|
||||||
|
for (const fo of allFraisOps) {
|
||||||
|
if (!ids.has(fo.investissement_id)) continue;
|
||||||
|
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
|
||||||
|
const d = fo.date_operation?.slice(0, 10);
|
||||||
|
if (!d || d > cutoff) continue;
|
||||||
|
flowsNet.push({ amount: -(fo.montant || 0), date: d });
|
||||||
|
}
|
||||||
let estime = false;
|
let estime = false;
|
||||||
for (const r of chartRows) {
|
for (const r of chartRows) {
|
||||||
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
||||||
@@ -907,7 +941,7 @@ export default function Plateformes() {
|
|||||||
flowsBrut.sort(byDate);
|
flowsBrut.sort(byDate);
|
||||||
flowsNet.sort(byDate);
|
flowsNet.sort(byDate);
|
||||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
|
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
|
||||||
}, [chartRows, allRembs, allReinvests, selectedYear, reinvestCumulParInv, capRembParInv]);
|
}, [chartRows, allRembs, allReinvests, allFraisOps, selectedYear, reinvestCumulParInv, capRembParInv]);
|
||||||
|
|
||||||
/* ── XIRR "attendu" (théorique) — mêmes flux réels que ci-dessus, mais les prêts
|
/* ── XIRR "attendu" (théorique) — mêmes flux réels que ci-dessus, mais les prêts
|
||||||
non soldés sont valorisés via leur échéancier prévisionnel (simul_remboursements)
|
non soldés sont valorisés via leur échéancier prévisionnel (simul_remboursements)
|
||||||
@@ -944,6 +978,16 @@ export default function Plateformes() {
|
|||||||
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
||||||
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
||||||
}
|
}
|
||||||
|
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
|
||||||
|
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
|
||||||
|
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
|
||||||
|
for (const fo of allFraisOps) {
|
||||||
|
if (!ids.has(fo.investissement_id)) continue;
|
||||||
|
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
|
||||||
|
const d = fo.date_operation?.slice(0, 10);
|
||||||
|
if (!d || d > cutoff) continue;
|
||||||
|
flowsNet.push({ amount: -(fo.montant || 0), date: d });
|
||||||
|
}
|
||||||
// Échéances futures prévues, groupées par investissement
|
// Échéances futures prévues, groupées par investissement
|
||||||
const futureSimulParInv = {};
|
const futureSimulParInv = {};
|
||||||
for (const sr of allSimulRembs) {
|
for (const sr of allSimulRembs) {
|
||||||
@@ -977,7 +1021,7 @@ export default function Plateformes() {
|
|||||||
flowsBrut.sort(byDate);
|
flowsBrut.sort(byDate);
|
||||||
flowsNet.sort(byDate);
|
flowsNet.sort(byDate);
|
||||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
|
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
|
||||||
}, [chartRows, allRembs, allReinvests, allSimulRembs, selectedYear, reinvestCumulParInv, capRembParInv, pfuRates]);
|
}, [chartRows, allRembs, allReinvests, allFraisOps, allSimulRembs, selectedYear, reinvestCumulParInv, capRembParInv, pfuRates]);
|
||||||
|
|
||||||
const netMode = displayMode === 'net';
|
const netMode = displayMode === 'net';
|
||||||
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1;
|
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1;
|
||||||
@@ -1235,6 +1279,45 @@ export default function Plateformes() {
|
|||||||
return true;
|
return true;
|
||||||
}), [mvNormalizedSouscriptionMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
}), [mvNormalizedSouscriptionMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
||||||
|
|
||||||
|
// Frais d'opération (Étape 3, 31/08/26) — réplique de normalizedFraisMouvements dans
|
||||||
|
// DepotsRetraits.jsx, cf. commentaire d'en-tête de ce bloc ONGLET "MOUVEMENTS PORTE-MONNAIE".
|
||||||
|
const mvNormalizedFraisMouvements = useMemo(() => {
|
||||||
|
const out = [];
|
||||||
|
for (const fr of allFraisOps) {
|
||||||
|
const inv = mvInvById.get(fr.investissement_id);
|
||||||
|
if (!inv) continue;
|
||||||
|
out.push({
|
||||||
|
id: `frais_${fr.id}`,
|
||||||
|
_fraisId: fr.id,
|
||||||
|
_invId: inv.id,
|
||||||
|
_rembId: fr.remboursement_id || null,
|
||||||
|
plateforme_id: inv.plateforme_id,
|
||||||
|
plateforme_nom: inv.plateforme_nom,
|
||||||
|
plateforme_detenteur_nom: inv.plateforme_detenteur_nom,
|
||||||
|
date_operation: fr.date_operation,
|
||||||
|
type: 'frais',
|
||||||
|
montant: fr.montant,
|
||||||
|
mode_reglement: fr.mode_reglement,
|
||||||
|
compte_nom: fr.compte_nom || null,
|
||||||
|
origine: fr.origine,
|
||||||
|
categorie: fr.categorie,
|
||||||
|
libelle: inv.nom_projet || '',
|
||||||
|
source: 'frais_operation',
|
||||||
|
investisseur_id: inv.investisseur_id ?? null,
|
||||||
|
notes: fr.notes || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [allFraisOps, mvInvById]);
|
||||||
|
|
||||||
|
const mvFilteredFraisMouvements = useMemo(() => mvNormalizedFraisMouvements.filter(r => {
|
||||||
|
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||||
|
const [y, m] = r.date_operation.split('-');
|
||||||
|
if (mvFilter.year && y !== mvFilter.year) return false;
|
||||||
|
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
|
||||||
|
return true;
|
||||||
|
}), [mvNormalizedFraisMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
||||||
|
|
||||||
// Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais
|
// Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais
|
||||||
// pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours
|
// pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours
|
||||||
// d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les
|
// d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les
|
||||||
@@ -1245,13 +1328,14 @@ export default function Plateformes() {
|
|||||||
const set = new Set();
|
const set = new Set();
|
||||||
for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||||
for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||||
|
for (const r of mvNormalizedFraisMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||||
for (const d of allDepots) {
|
for (const d of allDepots) {
|
||||||
if (!inScope(d.plateforme_id) || !d.remboursement_id) continue;
|
if (!inScope(d.plateforme_id) || !d.remboursement_id) continue;
|
||||||
const p = mvRembProjetById.get(d.remboursement_id);
|
const p = mvRembProjetById.get(d.remboursement_id);
|
||||||
if (p) set.add(p);
|
if (p) set.add(p);
|
||||||
}
|
}
|
||||||
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||||
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
|
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
|
||||||
|
|
||||||
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
|
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
|
||||||
// COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/
|
// COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/
|
||||||
@@ -1261,7 +1345,8 @@ export default function Plateformes() {
|
|||||||
// Voir [[project_mouvements_solde_indicatif]].
|
// Voir [[project_mouvements_solde_indicatif]].
|
||||||
const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
|
const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
|
||||||
...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements,
|
...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements,
|
||||||
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements]);
|
...mvNormalizedFraisMouvements,
|
||||||
|
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, mvNormalizedFraisMouvements]);
|
||||||
|
|
||||||
const mvTableRows = useMemo(() => {
|
const mvTableRows = useMemo(() => {
|
||||||
const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait';
|
const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait';
|
||||||
@@ -1276,11 +1361,14 @@ export default function Plateformes() {
|
|||||||
const subPart = mvFilteredSouscriptionMouvements
|
const subPart = mvFilteredSouscriptionMouvements
|
||||||
.filter(r => !mvFilter.type || r.type === mvFilter.type)
|
.filter(r => !mvFilter.type || r.type === mvFilter.type)
|
||||||
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||||
|
const fraisPart = mvFilteredFraisMouvements
|
||||||
|
.filter(r => !mvFilter.type || r.type === mvFilter.type)
|
||||||
|
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||||
return sortMouvements(
|
return sortMouvements(
|
||||||
[...drPart, ...corrPart, ...rembPart, ...subPart]
|
[...drPart, ...corrPart, ...rembPart, ...subPart, ...fraisPart]
|
||||||
.filter(r => !mvFilter.projet || r._projet === mvFilter.projet)
|
.filter(r => !mvFilter.projet || r._projet === mvFilter.projet)
|
||||||
).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) }));
|
).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) }));
|
||||||
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]);
|
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilteredFraisMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage,
|
pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage,
|
||||||
@@ -1641,10 +1729,11 @@ export default function Plateformes() {
|
|||||||
const rendementAttendu = netMode ? plateformeXirrAttendu.net : plateformeXirrAttendu.brut;
|
const rendementAttendu = netMode ? plateformeXirrAttendu.net : plateformeXirrAttendu.brut;
|
||||||
const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui";
|
const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui";
|
||||||
const periodLabel = selectedYear ? `Sur l'année ${selectedYear}` : "Depuis le début";
|
const periodLabel = selectedYear ? `Sur l'année ${selectedYear}` : "Depuis le début";
|
||||||
|
const fraisExplication = ` Les frais réglés hors remboursement (via le porte-monnaie ou un compte courant) sont eux aussi comptés comme une sortie d'argent dans ce calcul net — en plus des frais déjà déduits à la source ou d'un remboursement, toujours pris en compte.`;
|
||||||
const xirrExplication =
|
const xirrExplication =
|
||||||
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
|
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
|
||||||
`des prêts de cette sélection (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
|
`des prêts de cette sélection (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
|
||||||
`Chaque flux est pondéré par sa date exacte.\n\n` +
|
`Chaque flux est pondéré par sa date exacte.` + (netMode ? fraisExplication : '') + `\n\n` +
|
||||||
(plateformeXirr.estime
|
(plateformeXirr.estime
|
||||||
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé au ${cutoffLabel} — c'est donc une estimation, pas un taux définitif.`
|
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé au ${cutoffLabel} — c'est donc une estimation, pas un taux définitif.`
|
||||||
: 'Tous les prêts de cette sélection sont soldés : taux définitif.');
|
: 'Tous les prêts de cette sélection sont soldés : taux définitif.');
|
||||||
@@ -1653,7 +1742,7 @@ export default function Plateformes() {
|
|||||||
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
|
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
|
||||||
`que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` +
|
`que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` +
|
||||||
`date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` +
|
`date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` +
|
||||||
`"comme racheté au ${cutoffLabel}", sans compter les intérêts futurs.\n\n` +
|
`"comme racheté au ${cutoffLabel}", sans compter les intérêts futurs.` + (netMode ? fraisExplication : '') + `\n\n` +
|
||||||
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` +
|
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` +
|
||||||
`(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` +
|
`(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` +
|
||||||
`valorisé de la même façon que dans le XIRR réel.`;
|
`valorisé de la même façon que dans le XIRR réel.`;
|
||||||
@@ -2655,6 +2744,7 @@ export default function Plateformes() {
|
|||||||
<option value="cashback">Cashback</option>
|
<option value="cashback">Cashback</option>
|
||||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||||
<option value="souscription">Souscriptions</option>
|
<option value="souscription">Souscriptions</option>
|
||||||
|
<option value="frais">Frais d'opération</option>
|
||||||
<option value="prelevement_ir">Prélèvements IR</option>
|
<option value="prelevement_ir">Prélèvements IR</option>
|
||||||
<option value="prelevement_csg">Prélèvements CSG</option>
|
<option value="prelevement_csg">Prélèvements CSG</option>
|
||||||
<option value="prelevement_local">Prélèvements étranger</option>
|
<option value="prelevement_local">Prélèvements étranger</option>
|
||||||
@@ -2843,6 +2933,55 @@ export default function Plateformes() {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (r.type === 'frais') {
|
||||||
|
const horsPM = r.mode_reglement === 'compte_courant';
|
||||||
|
const rembLinked = r.mode_reglement === 'remboursement';
|
||||||
|
return (
|
||||||
|
<tr key={r.id}
|
||||||
|
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||||
|
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
|
||||||
|
style={{ cursor: 'pointer', opacity: horsPM ? 0.75 : 1 }}>
|
||||||
|
<td>{fmtDate(r.date_operation)}</td>
|
||||||
|
<td>{r.plateforme_nom}</td>
|
||||||
|
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
|
||||||
|
<td>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
|
||||||
|
padding: '2px 7px', borderRadius: 4,
|
||||||
|
background: 'color-mix(in srgb, #f59e0b 15%, transparent)',
|
||||||
|
color: '#f59e0b',
|
||||||
|
}}>frais</span>
|
||||||
|
{horsPM && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||||
|
padding: '1px 5px', borderRadius: 3,
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} title="Réglé via un compte courant — aucun effet sur le solde du porte-monnaie">HORS PM</span>
|
||||||
|
)}
|
||||||
|
{rembLinked && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
|
||||||
|
padding: '1px 5px', borderRadius: 3,
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}} title="Déjà déduit du montant net du remboursement — aucun effet supplémentaire sur le solde du porte-monnaie">DÉDUIT REMB.</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted">{r._projet || '—'}</td>
|
||||||
|
<td className={horsPM ? 'num text-muted' : 'num danger'}>
|
||||||
|
− {fmtEUR(r.montant)}
|
||||||
|
</td>
|
||||||
|
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
|
||||||
|
<td style={{ width: 36, textAlign: 'center' }} />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
const isAuto = r.source === 'auto_remboursement';
|
const isAuto = r.source === 'auto_remboursement';
|
||||||
return (
|
return (
|
||||||
<tr key={r.id}
|
<tr key={r.id}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useUi } from '../context/UiContext.jsx';
|
|||||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||||
import InteretsChart from '../components/InteretsChart.jsx';
|
import InteretsChart from '../components/InteretsChart.jsx';
|
||||||
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
||||||
|
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
|
||||||
|
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||||
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
|
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
|
||||||
import DrillCellPanel from '../components/DrillCellPanel.jsx';
|
import DrillCellPanel from '../components/DrillCellPanel.jsx';
|
||||||
import RembFormModal from '../components/RembFormModal.jsx';
|
import RembFormModal from '../components/RembFormModal.jsx';
|
||||||
@@ -57,7 +59,8 @@ const empty = {
|
|||||||
capital: 0, cashback: 0,
|
capital: 0, cashback: 0,
|
||||||
interets_bruts_avant_local: 0,
|
interets_bruts_avant_local: 0,
|
||||||
taxe_locale: 0,
|
taxe_locale: 0,
|
||||||
interets_bruts: 0, prelev_sociaux: 0,
|
interets_bruts: 0, frais_ttc: 0, frais_mode_reglement: 'remboursement', frais_compte_id: '',
|
||||||
|
prelev_sociaux: 0,
|
||||||
prelev_forfaitaire: 0,
|
prelev_forfaitaire: 0,
|
||||||
statut: 'paye', notes: '',
|
statut: 'paye', notes: '',
|
||||||
methode_remboursement: 'portefeuille',
|
methode_remboursement: 'portefeuille',
|
||||||
@@ -181,6 +184,7 @@ export default function Remboursements() {
|
|||||||
const [corrections, setCorrections] = useState([]);
|
const [corrections, setCorrections] = useState([]);
|
||||||
const [investissements, setInvestissements] = useState([]);
|
const [investissements, setInvestissements] = useState([]);
|
||||||
const [plateformes, setPlateformes] = useState([]);
|
const [plateformes, setPlateformes] = useState([]);
|
||||||
|
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs
|
||||||
const [platLoaded, setPlatLoaded] = useState(false);
|
const [platLoaded, setPlatLoaded] = useState(false);
|
||||||
const [pfuRates, setPfuRates] = useState([]);
|
const [pfuRates, setPfuRates] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -266,12 +270,16 @@ export default function Remboursements() {
|
|||||||
setSelectedSimul(''); // reset sélection projection
|
setSelectedSimul(''); // reset sélection projection
|
||||||
try {
|
try {
|
||||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const [r, inv, corr] = await Promise.all([
|
const [r, inv, corr, fraisOps] = await Promise.all([
|
||||||
api.get('/remboursements', scopeParams),
|
api.get('/remboursements', scopeParams),
|
||||||
api.get('/investissements', scopeParams),
|
api.get('/investissements', scopeParams),
|
||||||
api.get('/corrections', scopeParams).catch(() => []),
|
api.get('/corrections', scopeParams).catch(() => []),
|
||||||
|
// /frais-operations sans investissement_id ne connaît que l'utilisateur (tous
|
||||||
|
// investisseurs), filtré ensuite via filterPlatId/rembPlatYear comme allRows —
|
||||||
|
// même pattern que allFraisOps dans Plateformes.jsx (Étape 5).
|
||||||
|
api.get('/frais-operations').catch(() => []),
|
||||||
]);
|
]);
|
||||||
setAllRows(r); setInvestissements(inv); setCorrections(corr);
|
setAllRows(r); setInvestissements(inv); setCorrections(corr); setAllFraisOps(fraisOps);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setDrillRefreshKey(k => k + 1);
|
setDrillRefreshKey(k => k + 1);
|
||||||
@@ -412,6 +420,39 @@ export default function Remboursements() {
|
|||||||
[byPlatform]
|
[byPlatform]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ── Étape 5 : reporting "Frais payés" — filtré comme le reste de la page par
|
||||||
|
filterPlatId (plateforme) et rembPlatYear (année), sur date_operation cette fois. ── */
|
||||||
|
const fraisFiltered = useMemo(() =>
|
||||||
|
allFraisOps.filter(f => {
|
||||||
|
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
|
||||||
|
if (rembPlatYear && (f.date_operation || '').slice(0, 4) !== rembPlatYear) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[allFraisOps, filterPlatId, rembPlatYear]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fraisPrevFiltered = useMemo(() =>
|
||||||
|
allFraisOps.filter(f => {
|
||||||
|
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
|
||||||
|
return (f.date_operation || '').slice(0, 4) === rembPrevYear;
|
||||||
|
}),
|
||||||
|
[allFraisOps, filterPlatId, rembPrevYear]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
|
||||||
|
const fraisPrevTotal = useMemo(() => fraisPrevFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisPrevFiltered]);
|
||||||
|
|
||||||
|
const byCategorie = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const f of fraisFiltered) {
|
||||||
|
const key = f.categorie || 'autre';
|
||||||
|
if (!map[key]) map[key] = { categorie: key, montant: 0, count: 0 };
|
||||||
|
map[key].montant += f.montant || 0;
|
||||||
|
map[key].count += 1;
|
||||||
|
}
|
||||||
|
return Object.values(map).sort((a, b) => b.montant - a.montant);
|
||||||
|
}, [fraisFiltered]);
|
||||||
|
|
||||||
/* ── Tableau remboursements : filtrage client-side ── */
|
/* ── Tableau remboursements : filtrage client-side ── */
|
||||||
const tableRows = useMemo(() => {
|
const tableRows = useMemo(() => {
|
||||||
// Remboursements filtrés
|
// Remboursements filtrés
|
||||||
@@ -547,7 +588,12 @@ export default function Remboursements() {
|
|||||||
capital: r.capital, cashback: r.cashback || 0,
|
capital: r.capital, cashback: r.cashback || 0,
|
||||||
interets_bruts_avant_local: r.interets_bruts_avant_local || 0,
|
interets_bruts_avant_local: r.interets_bruts_avant_local || 0,
|
||||||
taxe_locale: r.taxe_locale || 0,
|
taxe_locale: r.taxe_locale || 0,
|
||||||
interets_bruts: r.interets_bruts,
|
interets_bruts: r.interets_bruts, frais_ttc: r.frais_ttc || 0,
|
||||||
|
// Étape 5 bis (01/09/26) : le mode de règlement du frais n'est pas stocké sur le
|
||||||
|
// remboursement lui-même mais sur la ligne frais_operations liée — recherchée dans
|
||||||
|
// allFraisOps déjà chargé, par remboursement_id (cf. RembFormModal.jsx).
|
||||||
|
frais_mode_reglement: (allFraisOps.find(f => f.remboursement_id === r.id)?.mode_reglement) || 'remboursement',
|
||||||
|
frais_compte_id: String(allFraisOps.find(f => f.remboursement_id === r.id)?.compte_id || ''),
|
||||||
prelev_sociaux: r.prelev_sociaux, prelev_forfaitaire: r.prelev_forfaitaire,
|
prelev_sociaux: r.prelev_sociaux, prelev_forfaitaire: r.prelev_forfaitaire,
|
||||||
statut: r.statut, notes: r.notes || '',
|
statut: r.statut, notes: r.notes || '',
|
||||||
methode_remboursement: r.methode_remboursement || 'portefeuille',
|
methode_remboursement: r.methode_remboursement || 'portefeuille',
|
||||||
@@ -648,8 +694,10 @@ export default function Remboursements() {
|
|||||||
|
|
||||||
const round2 = (n) => Math.round(n * 100) / 100;
|
const round2 = (n) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
const computeInteretsNets = (f) =>
|
const computeInteretsNets = (f) => {
|
||||||
round2(Number(f.interets_bruts || 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
const fraisReduit = (!f.frais_mode_reglement || f.frais_mode_reglement === 'remboursement') ? Number(f.frais_ttc || 0) : 0;
|
||||||
|
return round2(Number(f.interets_bruts || 0) - fraisReduit - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
||||||
|
};
|
||||||
|
|
||||||
const computeNet = (f) =>
|
const computeNet = (f) =>
|
||||||
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
|
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
|
||||||
@@ -674,9 +722,17 @@ export default function Remboursements() {
|
|||||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
|
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
|
||||||
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
||||||
const bruts = Number(next.interets_bruts) || 0;
|
// Base imposable nette de frais (Étape 4, 31/08/26) — même formule que computeChamps()
|
||||||
|
// côté backend et que POST /reprocess : les prélèvements doivent porter sur
|
||||||
|
// (interets_bruts - frais_ttc), pas sur le brut seul, sous peine de sur-taxer le coupon
|
||||||
|
// (confirmé par Olivier sur l'avis d'opéré Kyoseil-AM/Fundora du 02/07/2026). Depuis
|
||||||
|
// l'Étape 5 bis (01/09/26), cette réduction ne s'applique QUE si le frais est retenu SUR ce
|
||||||
|
// remboursement (frais_mode_reglement === 'remboursement', par défaut) — un frais réglé à
|
||||||
|
// part (portefeuille/compte courant) n'affecte pas cette base.
|
||||||
|
const fraisReduit = (!next.frais_mode_reglement || next.frais_mode_reglement === 'remboursement') ? (Number(next.frais_ttc) || 0) : 0;
|
||||||
|
const bruts = round2((Number(next.interets_bruts) || 0) - fraisReduit);
|
||||||
const rates = getRatesForYear(dateStr);
|
const rates = getRatesForYear(dateStr);
|
||||||
if (rates) {
|
if (rates) {
|
||||||
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
||||||
@@ -743,6 +799,7 @@ export default function Remboursements() {
|
|||||||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
|
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
|
||||||
taxe_locale: Number(form.taxe_locale || 0),
|
taxe_locale: Number(form.taxe_locale || 0),
|
||||||
interets_bruts: Number(form.interets_bruts || 0),
|
interets_bruts: Number(form.interets_bruts || 0),
|
||||||
|
frais_ttc: Number(form.frais_ttc || 0),
|
||||||
prelev_sociaux: Number(form.prelev_sociaux || 0),
|
prelev_sociaux: Number(form.prelev_sociaux || 0),
|
||||||
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
|
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
|
||||||
statut: form.statut,
|
statut: form.statut,
|
||||||
@@ -758,6 +815,9 @@ export default function Remboursements() {
|
|||||||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
|
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
|
||||||
taxe_locale: Number(form.taxe_locale || 0),
|
taxe_locale: Number(form.taxe_locale || 0),
|
||||||
interets_bruts: Number(form.interets_bruts || 0),
|
interets_bruts: Number(form.interets_bruts || 0),
|
||||||
|
frais_ttc: Number(form.frais_ttc || 0),
|
||||||
|
frais_mode_reglement: form.frais_mode_reglement || 'remboursement',
|
||||||
|
frais_compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
|
||||||
prelev_sociaux: Number(form.prelev_sociaux || 0),
|
prelev_sociaux: Number(form.prelev_sociaux || 0),
|
||||||
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
|
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
|
||||||
statut: form.statut,
|
statut: form.statut,
|
||||||
@@ -881,6 +941,8 @@ export default function Remboursements() {
|
|||||||
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
|
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
|
||||||
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
|
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
|
||||||
onClick={() => setActiveTab('projections')}>Projections Échéances</button>
|
onClick={() => setActiveTab('projections')}>Projections Échéances</button>
|
||||||
|
<button className={`dr-tab${activeTab === 'frais' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('frais')}>Frais</button>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{/* ══ ONGLET PLATEFORMES ══ */}
|
{/* ══ ONGLET PLATEFORMES ══ */}
|
||||||
@@ -1265,7 +1327,35 @@ export default function Remboursements() {
|
|||||||
{(isBonusLike || isCorrection) ? '—' : fmtEUR(Math.max(0, r.capital_restant_du ?? 0))}
|
{(isBonusLike || isCorrection) ? '—' : fmtEUR(Math.max(0, r.capital_restant_du ?? 0))}
|
||||||
</td>
|
</td>
|
||||||
<td className="num">{r.cashback ? fmtEUR(r.cashback) : '—'}</td>
|
<td className="num">{r.cashback ? fmtEUR(r.cashback) : '—'}</td>
|
||||||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.interets_bruts)}</td>
|
<td className="num">
|
||||||
|
{(isBonus || isCorrection) ? '—' : (
|
||||||
|
<>
|
||||||
|
{fmtEUR(r.interets_bruts)}
|
||||||
|
{/* Icône uniquement si le frais réduit réellement ce montant (mode
|
||||||
|
'remboursement', par défaut) — un frais réglé à part (Étape 5 bis,
|
||||||
|
01/09/26) n'a aucun impact sur les intérêts bruts affichés ici. */}
|
||||||
|
{!!r.frais_ttc && (!r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement') && (
|
||||||
|
<span
|
||||||
|
className="cell-tooltip"
|
||||||
|
data-tooltip={
|
||||||
|
`Intérêts bruts avant frais : ${fmtEUR(r.interets_bruts)}\n` +
|
||||||
|
`Frais de distribution : -${fmtEUR(r.frais_ttc)}\n` +
|
||||||
|
`Base après frais (avant prélèvements) : ${fmtEUR(round2(r.interets_bruts - r.frais_ttc))}\n` +
|
||||||
|
`Prélèv. sociaux : ${fmtEUR(r.prelev_sociaux)}\n` +
|
||||||
|
`Impôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}\n` +
|
||||||
|
`Montant net reçu : ${fmtEUR(r.net_recu)}`
|
||||||
|
}
|
||||||
|
style={{ marginLeft: 5, color: 'var(--warning, #b45309)', verticalAlign: 'middle' }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_sociaux)}</td>
|
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_sociaux)}</td>
|
||||||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_forfaitaire)}</td>
|
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_forfaitaire)}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
@@ -1471,6 +1561,97 @@ export default function Remboursements() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ══ ONGLET FRAIS (Étape 5 — reporting consolidé) ══ */}
|
||||||
|
{activeTab === 'frais' && (
|
||||||
|
<div style={{ padding: '0 24px' }}>
|
||||||
|
{/* ── KPI ── */}
|
||||||
|
<div className="dr-kpi-row" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">
|
||||||
|
Frais payés{rembPlatYear && ` en ${rembPlatYear}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||||
|
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
|
||||||
|
{rembPlatYear && <TrendBadge current={fraisTotal} prev={fraisPrevTotal} invert />}
|
||||||
|
</div>
|
||||||
|
{rembPlatYear && (
|
||||||
|
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
|
||||||
|
{fmtEUR(fraisPrevTotal)} en {rembPrevYear}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Graphique ── */}
|
||||||
|
<div className="charts-row" style={{ marginBottom: 16 }}>
|
||||||
|
<FraisDistributionChart rows={fraisFiltered} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tableau par catégorie ── */}
|
||||||
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>
|
||||||
|
Répartition par catégorie
|
||||||
|
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
|
||||||
|
</h3>
|
||||||
|
{filterPlatId && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||||
|
Filtré sur cette plateforme
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Catégorie</th>
|
||||||
|
<th className="num">Montant</th>
|
||||||
|
<th className="num">Nombre</th>
|
||||||
|
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{byCategorie.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
{byCategorie.map(c => {
|
||||||
|
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<tr key={c.categorie}>
|
||||||
|
<td>{FRAIS_CATEGORIE_LABELS[c.categorie] || c.categorie}</td>
|
||||||
|
<td className="num">{fmtEUR(c.montant)}</td>
|
||||||
|
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
|
||||||
|
<td className="num">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
|
||||||
|
{poids.toFixed(1)} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
{byCategorie.length > 1 && (
|
||||||
|
<tfoot>
|
||||||
|
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||||
|
<td>Total — {byCategorie.length} catégories</td>
|
||||||
|
<td className="num">{fmtEUR(fraisTotal)}</td>
|
||||||
|
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
|
||||||
|
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ══ MODAL ══ */}
|
{/* ══ MODAL ══ */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Liste d'investissements disponibles dans le select
|
// Liste d'investissements disponibles dans le select
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ Tous en lecture seule (`readOnlyHint: true`) :
|
|||||||
| `crowdlending_list_investissements` | Liste des investissements, filtrable par statut |
|
| `crowdlending_list_investissements` | Liste des investissements, filtrable par statut |
|
||||||
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
|
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
|
||||||
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
|
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
|
||||||
|
| `crowdlending_list_frais_operations` | Frais liés aux investissements (souscription, gestion, distribution), filtrable par période |
|
||||||
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash |
|
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash |
|
||||||
| `crowdlending_list_objectifs` | Objectifs de versement et de capital investi, à comparer au réalisé |
|
| `crowdlending_list_objectifs` | Objectifs de versement et de capital investi, à comparer au réalisé |
|
||||||
| `crowdlending_get_taxreport` | Synthèse fiscale annuelle (CERFA 2042/2561/2047/2778-SD) — aide à la déclaration, pas une source officielle |
|
| `crowdlending_get_taxreport` | Synthèse fiscale annuelle (CERFA 2042/2561/2047/2778-SD) — aide à la déclaration, pas une source officielle |
|
||||||
|
|||||||
+53
-3
@@ -76,7 +76,10 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
"retard/procédure), montant remboursé, intérêts bruts/nets perçus, " +
|
"retard/procédure), montant remboursé, intérêts bruts/nets perçus, " +
|
||||||
"capital reçu, total dépôts/retraits, et gain_net_depuis_debut (intérêts " +
|
"capital reçu, total dépôts/retraits, et gain_net_depuis_debut (intérêts " +
|
||||||
"nets + cashback/bonus de parrainage ou de plateforme + corrections de " +
|
"nets + cashback/bonus de parrainage ou de plateforme + corrections de " +
|
||||||
"solde, cumulés sur tout l'historique — équivalent au KPI \"Gain net " +
|
"solde − frais réglés hors remboursement (porte-monnaie ou compte " +
|
||||||
|
"courant du détenteur ; détail dans gain_net_depuis_debut." +
|
||||||
|
"frais_hors_remboursement — voir aussi crowdlending_list_frais_operations), " +
|
||||||
|
"cumulés sur tout l'historique — équivalent au KPI \"Gain net " +
|
||||||
"depuis le début\" de l'app, toujours un total, jamais filtré par année, " +
|
"depuis le début\" de l'app, toujours un total, jamais filtré par année, " +
|
||||||
"reste valable même après un retrait complet des plateformes). Les " +
|
"reste valable même après un retrait complet des plateformes). Les " +
|
||||||
"montants d'investissements sont des soldes actuels (photo à " +
|
"montants d'investissements sont des soldes actuels (photo à " +
|
||||||
@@ -123,7 +126,13 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
description: withSource(
|
description: withSource(
|
||||||
"Retourne le détail complet d'un investissement (identifié par son id, " +
|
"Retourne le détail complet d'un investissement (identifié par son id, " +
|
||||||
"obtenu via crowdlending_list_investissements), y compris la liste de " +
|
"obtenu via crowdlending_list_investissements), y compris la liste de " +
|
||||||
"ses remboursements réels perçus (capital, intérêts bruts/nets, cashback)."),
|
"ses remboursements réels perçus (capital, intérêts bruts/nets, cashback, " +
|
||||||
|
"et frais_ttc/frais_mode_reglement — le frais ne réduit interets_nets/" +
|
||||||
|
"net_recu que si frais_mode_reglement vaut \"remboursement\") et le " +
|
||||||
|
"tableau frais_operations : tous les frais liés à cet investissement " +
|
||||||
|
"(souscription, gestion, distribution, autre), quel que soit leur mode " +
|
||||||
|
"de règlement (source, porte-monnaie, compte courant, ou retenu sur un " +
|
||||||
|
"remboursement)."),
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
id: z.number().int().positive().describe("Identifiant de l'investissement"),
|
id: z.number().int().positive().describe("Identifiant de l'investissement"),
|
||||||
},
|
},
|
||||||
@@ -141,7 +150,13 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
title: withLabel('Liste des remboursements'),
|
title: withLabel('Liste des remboursements'),
|
||||||
description: withSource(
|
description: withSource(
|
||||||
"Liste les remboursements réels perçus (toutes plateformes confondues), " +
|
"Liste les remboursements réels perçus (toutes plateformes confondues), " +
|
||||||
"avec le nom du projet et de la plateforme. Filtrable par période."),
|
"avec le nom du projet et de la plateforme. Filtrable par période. " +
|
||||||
|
"Chaque ligne inclut frais_ttc (montant de frais saisi sur ce " +
|
||||||
|
"remboursement) et frais_mode_reglement : le frais ne réduit " +
|
||||||
|
"interets_nets/net_recu que si frais_mode_reglement vaut " +
|
||||||
|
"\"remboursement\" — avec \"portefeuille\" ou \"compte_courant\", le frais " +
|
||||||
|
"est réglé à part (voir crowdlending_list_frais_operations) et n'a " +
|
||||||
|
"aucun impact sur ce remboursement, ne pas le soustraire une seconde fois."),
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
||||||
.describe('Date de début au format YYYY-MM-DD (incluse)'),
|
.describe('Date de début au format YYYY-MM-DD (incluse)'),
|
||||||
@@ -156,6 +171,41 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'crowdlending_list_frais_operations',
|
||||||
|
{
|
||||||
|
title: withLabel('Liste des frais liés aux investissements'),
|
||||||
|
description: withSource(
|
||||||
|
"Liste les frais facturés par certaines plateformes en dehors des " +
|
||||||
|
"intérêts/prélèvements fiscaux habituels : frais d'entrée à la " +
|
||||||
|
"souscription, frais de gestion, frais sur distribution, ou autre " +
|
||||||
|
"(catégorie renseignée dans `categorie`). `mode_reglement` indique " +
|
||||||
|
"comment le frais a été réglé — \"source\" : aucun mouvement de " +
|
||||||
|
"trésorerie réel, montant purement indicatif (déjà déduit par la " +
|
||||||
|
"plateforme en amont) ; \"portefeuille\" : débité du porte-monnaie de " +
|
||||||
|
"la plateforme ; \"compte_courant\" : débité d'un compte courant du " +
|
||||||
|
"détenteur (`compte_nom`) ; \"remboursement\" : retenu directement sur " +
|
||||||
|
"le remboursement lié (`remboursement_id`), déjà pris en compte dans " +
|
||||||
|
"les champs interets_nets/net_recu de ce remboursement (voir " +
|
||||||
|
"crowdlending_list_remboursements) — ne pas le compter une seconde " +
|
||||||
|
"fois. Seuls les modes \"portefeuille\" et \"compte_courant\" " +
|
||||||
|
"représentent une sortie d'argent réelle et séparée : c'est ce " +
|
||||||
|
"sous-ensemble qui est déduit de gain_net_depuis_debut sur " +
|
||||||
|
"crowdlending_get_dashboard. Filtrable par période."),
|
||||||
|
inputSchema: {
|
||||||
|
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
||||||
|
.describe('Date de début au format YYYY-MM-DD (incluse)'),
|
||||||
|
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
||||||
|
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
|
||||||
|
},
|
||||||
|
annotations: READ_ONLY_ANNOTATIONS,
|
||||||
|
},
|
||||||
|
async ({ date_debut, date_fin }) => {
|
||||||
|
try { return toolResult(await apiGet('/frais-operations', { date_debut, date_fin })); }
|
||||||
|
catch (e) { return toolError(e); }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'crowdlending_list_depots_retraits',
|
'crowdlending_list_depots_retraits',
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user