Mise en place de la feature de gestion des Frais

This commit is contained in:
ocroguennec committed 2026-09-01 22:16:18 +02:00
1 parent 9ea078302d
commit 0334a63139
27 files changed
+2394 -110

No files matched your search

+114
View File
@@ -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;
+36 -3
View File
@@ -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
+183
View File
@@ -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;
+115 -20
View File
@@ -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,
); );
+18 -2
View File
@@ -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),
}, },
}); });
}); });
+61
View File
@@ -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;
+2
View File
@@ -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);
+30 -6
View File
@@ -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); }
}); });
+9 -1
View File
@@ -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
+2
View File
@@ -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

+98
View File
@@ -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

+27 -1
View File
@@ -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,7 +87,30 @@ 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;
const platFrais = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
// Étape "Frais" : uniquement à la création (une fois la souscription actée, le frais initial
// é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", "Informations générales de l'investissement",
'Données financières et échéancier', 'Données financières et échéancier',
'Catégories, secteurs, autres', 'Catégories, secteurs, autres',
@@ -95,11 +118,10 @@ export default function InvestissementFormModal({
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}
+93 -6
View File
@@ -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>
+2 -1
View File
@@ -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,
}} }}
/> />
); );
+74 -2
View File
@@ -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>
+63 -4
View File
@@ -264,6 +264,7 @@ export default function Aide() {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Remboursements crédités au porte-monnaie<br /> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Remboursements crédités au porte-monnaie<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Bonus (parrainage / plateforme)<br /> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Bonus (parrainage / plateforme)<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;− Capital investi (en cours ou remboursé)<br /> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;− Capital investi (en cours ou remboursé)<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;− Frais (déduits à la souscription ou réglés via le porte-monnaie)<br />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ Corrections de solde &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ 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>
+61 -11
View File
@@ -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>
+105 -4
View File
@@ -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}
+466 -16
View File
@@ -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)}`);
}
if (showFraisIcon) {
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)} {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>
);
})()}
<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}
+93 -2
View File
@@ -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,
+150 -11
View File
@@ -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}
+190 -9
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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',
{ {