diff --git a/backend/src/db/index.js b/backend/src/db/index.js index d0ce85a..0a58952 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index 5cd34dc..f5a1963 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -125,6 +125,23 @@ router.get('/', (req, res) => { GROUP BY i.plateforme_id `).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) const correctionsPerPlat = db.prepare(` 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 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 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; const cashByPlatformEnriched = cashByPlatform.map(p => ({ @@ -340,7 +358,18 @@ router.get('/solde-historique', (req, res) => { AND i.date_souscription <= ? `).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(` SELECT COALESCE(SUM(montant), 0) AS total_correction FROM corrections_solde @@ -353,6 +382,7 @@ router.get('/solde-historique', (req, res) => { + remb.remb_wallet + bonus.bonus_wallet - capital.capital_investi + - frais.total_frais + corrections.total_correction ) * 100) / 100; @@ -923,11 +953,14 @@ router.get('/detail-cellule', (req, res) => { SELECT r.id, r.investissement_id, r.date_remb, 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, i.nom_projet, i.plateforme_id, 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 JOIN investissements i ON i.id = r.investissement_id JOIN investisseurs inv ON inv.id = i.investisseur_id diff --git a/backend/src/routes/fraisOperations.js b/backend/src/routes/fraisOperations.js new file mode 100644 index 0000000..6ac7667 --- /dev/null +++ b/backend/src/routes/fraisOperations.js @@ -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; diff --git a/backend/src/routes/remboursements.js b/backend/src/routes/remboursements.js index 0ac376a..2125f29 100644 --- a/backend/src/routes/remboursements.js +++ b/backend/src/routes/remboursements.js @@ -4,6 +4,7 @@ import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { requireInvestisseur } from '../middleware/investisseurScope.js'; import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js'; +import { assertOwnedCompte } from './fraisOperations.js'; const router = Router(); @@ -18,6 +19,14 @@ const Schema = z.object({ interets_bruts_avant_local: z.number().nonnegative().default(0), taxe_locale: 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_forfaitaire: z.number().nonnegative().default(0), 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. */ 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 - ? 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; 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) { if (!investissement_id) return; 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, inv.nom AS investisseur_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 LEFT JOIN plateformes p ON p.id = b.plateforme_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') { 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(` INSERT INTO remboursements (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) - VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?) + VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?) `).run( body.investissement_id, body.date_remb, body.capital, body.cashback, 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, body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null, ); syncInvestissementStatut(body.investissement_id); adjustSimulForActuals(db, body.investissement_id); 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 ────────────────────────── { const inv = db.prepare( @@ -245,12 +319,12 @@ router.post('/', (req, res, next) => { const r = db.prepare(` INSERT INTO remboursements (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) - VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?, ?,?, ?, 'manuel', ?) + VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?) `).run( 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, interets_nets, net_recu, body.statut, body.notes || null, ); @@ -278,9 +352,13 @@ router.post('/reprocess', (req, res, next) => { const rembs = db.prepare(` 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, - 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 JOIN investissements i ON i.id = r.investissement_id JOIN plateformes p ON p.id = i.plateforme_id @@ -302,6 +380,8 @@ router.post('/reprocess', (req, res, next) => { interets_nets=?, net_recu=? 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(` UPDATE depots_retraits SET montant=? @@ -328,18 +408,25 @@ router.post('/reprocess', (req, res, next) => { 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) if (rates) { - prelev_sociaux = round2(interets_bruts * rates.prelev_sociaux / 100); - prelev_forfaitaire = round2(interets_bruts * rates.impot_revenu / 100); + prelev_sociaux = round2(baseNetteDeFrais * rates.prelev_sociaux / 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 - // 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 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); 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)); if (body.type === 'normal') { + if (body.frais_mode_reglement === 'compte_courant') assertOwnedCompte(body.frais_compte_id, req.user.id); db.prepare(` UPDATE remboursements 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=?, interets_nets=?, net_recu=?, statut=?, notes=?, methode_remboursement=?, compte_id=? WHERE id=? `).run( 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, interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement, body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null, @@ -394,14 +482,21 @@ router.put('/:id', (req, res, next) => { syncInvestissementStatut(body.investissement_id); adjustSimulForActuals(db, body.investissement_id); 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') { db.prepare(` 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=? WHERE id=? `).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, interets_nets, net_recu, body.statut, body.notes || null, id, ); diff --git a/backend/src/routes/v1/dashboard.js b/backend/src/routes/v1/dashboard.js index aef5b31..3d16c97 100644 --- a/backend/src/routes/v1/dashboard.js +++ b/backend/src/routes/v1/dashboard.js @@ -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 * 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 - * parrainage ou de plateforme + corrections de solde, sur tout - * l'historique du portefeuille — reste valable même après un retrait + * parrainage ou de plateforme + corrections de solde − frais réglés hors + * 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 * solde courant. * tags: [Dashboard] @@ -121,8 +123,21 @@ router.get('/', (req, res) => { WHERE ${invCond('investisseur_id')} `).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( gainNetRow.interets_nets_total + gainNetRow.cashback_total + correctionsRow.total + - fraisHorsRembRow.total ); res.json({ @@ -134,6 +149,7 @@ router.get('/', (req, res) => { interets_nets: round2(gainNetRow.interets_nets_total), cashback: round2(gainNetRow.cashback_total), corrections: round2(correctionsRow.total), + frais_hors_remboursement: round2(fraisHorsRembRow.total), }, }); }); diff --git a/backend/src/routes/v1/fraisOperations.js b/backend/src/routes/v1/fraisOperations.js new file mode 100644 index 0000000..8e2042e --- /dev/null +++ b/backend/src/routes/v1/fraisOperations.js @@ -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; diff --git a/backend/src/routes/v1/index.js b/backend/src/routes/v1/index.js index 2466c85..a3a97b4 100644 --- a/backend/src/routes/v1/index.js +++ b/backend/src/routes/v1/index.js @@ -3,6 +3,7 @@ import investisseurRouter from './investisseur.js'; import investissementsRouter from './investissements.js'; import remboursementsRouter from './remboursements.js'; import depotsRetraitsRouter from './depotsRetraits.js'; +import fraisOperationsRouter from './fraisOperations.js'; import dashboardRouter from './dashboard.js'; import objectifsRouter from './objectifs.js'; import taxreportRouter from './taxreport.js'; @@ -13,6 +14,7 @@ router.use('/investisseur', investisseurRouter); router.use('/investissements', investissementsRouter); router.use('/remboursements', remboursementsRouter); router.use('/depots-retraits', depotsRetraitsRouter); +router.use('/frais-operations', fraisOperationsRouter); router.use('/dashboard', dashboardRouter); router.use('/objectifs', objectifsRouter); router.use('/taxreport', taxreportRouter); diff --git a/backend/src/routes/v1/investissements.js b/backend/src/routes/v1/investissements.js index 9f4315d..2168763 100644 --- a/backend/src/routes/v1/investissements.js +++ b/backend/src/routes/v1/investissements.js @@ -50,6 +50,15 @@ router.get('/', (req, res) => { * /investissements/{id}: * get: * 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] * security: [{ ApiKeyAuth: [] }] * parameters: @@ -77,14 +86,29 @@ router.get('/:id', (req, res, next) => { if (!inv) throw new HttpError(404, 'Investissement introuvable'); const remboursements = db.prepare(` - SELECT id, date_remb, capital, interets_bruts, prelev_sociaux, - prelev_forfaitaire, cashback, interets_nets, net_recu, statut - FROM remboursements - WHERE investissement_id = ? - ORDER BY date_remb + SELECT r.id, r.date_remb, 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 + FROM remboursements r + WHERE r.investissement_id = ? + ORDER BY r.date_remb `).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); } }); diff --git a/backend/src/routes/v1/remboursements.js b/backend/src/routes/v1/remboursements.js index 75cb057..f083d50 100644 --- a/backend/src/routes/v1/remboursements.js +++ b/backend/src/routes/v1/remboursements.js @@ -8,6 +8,12 @@ const router = Router(); * /remboursements: * get: * 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] * security: [{ ApiKeyAuth: [] }] * parameters: @@ -33,7 +39,9 @@ router.get('/', (req, res) => { const rows = db.prepare(` 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 FROM remboursements r JOIN investissements i ON i.id = r.investissement_id diff --git a/backend/src/server.js b/backend/src/server.js index 222b535..9cdfd13 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -26,6 +26,7 @@ import notationRouter from './routes/notation.js'; import garantiesRouter from './routes/garanties.js'; import objectifsRouter from './routes/objectifs.js'; import reinvestissementsRouter from './routes/reinvestissements.js'; +import fraisOperationsRouter from './routes/fraisOperations.js'; import correctionsRouter from './routes/corrections.js'; import comptesRouter from './routes/comptes.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/objectifs', requireAuth, objectifsRouter); app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter); +app.use('/api/frais-operations', requireAuth, fraisOperationsRouter); app.use('/api/corrections', requireAuth, correctionsRouter); app.use('/api/comptes', requireAuth, comptesRouter); app.use('/api/preferences', requireAuth, preferencesRouter); diff --git a/design/Icones/png/Frais.png b/design/Icones/png/Frais.png new file mode 100644 index 0000000..b2223b8 Binary files /dev/null and b/design/Icones/png/Frais.png differ diff --git a/design/Icones/svg/Frais.svg b/design/Icones/svg/Frais.svg new file mode 100644 index 0000000..a5c901e --- /dev/null +++ b/design/Icones/svg/Frais.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/DrillCellPanel.jsx b/frontend/src/components/DrillCellPanel.jsx index fa0f5b9..80fae17 100644 --- a/frontend/src/components/DrillCellPanel.jsx +++ b/frontend/src/components/DrillCellPanel.jsx @@ -462,7 +462,33 @@ export default function DrillCellPanel({ if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts; else if (c.key === 'capital') v = r.capital ?? 0; else v = r.cashback ?? 0; - return {fmtEUR(v)}; + // 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 ( + + {fmtEUR(v)} + {showFraisIcon && ( + + + + + + + + )} + + ); })} {fmtEUR(recuRowValue(r))} diff --git a/frontend/src/components/FraisDistributionChart.jsx b/frontend/src/components/FraisDistributionChart.jsx new file mode 100644 index 0000000..0f049b0 --- /dev/null +++ b/frontend/src/components/FraisDistributionChart.jsx @@ -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 ( +
+ + {/* ── En-tête ── */} +
+ Frais +
+ par Catégorie + + + +
+
+ + {/* ── Treemap SVG ── */} +
+ + + + + + + + + + {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 ( + + + + ); + })} + + + {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 ( + handleMouseMove(e, { ...cell, pct, amt }, i)} + > + + + {isHovered && ( + + )} + + {textItems.map(l => ( + + {l.text} + + ))} + + + ); + })} + + + {/* ── Tooltip ── */} + {tooltip && tipStyle && ( +
+
+
+
{tooltip.cell.amt}
+
{tooltip.cell.name}
+
{tooltip.cell.pct} %
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/InvestissementFormModal.jsx b/frontend/src/components/InvestissementFormModal.jsx index 47f5c57..219459f 100644 --- a/frontend/src/components/InvestissementFormModal.jsx +++ b/frontend/src/components/InvestissementFormModal.jsx @@ -87,19 +87,41 @@ export default function InvestissementFormModal({ if (open) setStep(1); }, [open]); - const stepTitles = [ - "Informations générales de l'investissement", - 'Données financières et échéancier', - 'Catégories, secteurs, autres', - ]; + 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", + 'Données financières et échéancier', + 'Catégories, secteurs, autres', + ]; const goNext = () => { 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 platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement; const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe') && dayOfMonth(form.date_premiere_echeance) > 27; @@ -155,6 +177,10 @@ export default function InvestissementFormModal({ 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 next = { ...form, type_remb: t, @@ -280,8 +306,8 @@ export default function InvestissementFormModal({
{step === 1 && } {step > 1 && } - {step < 3 && } - {step === 3 && } + {step < totalSteps && } + {step === totalSteps && } } > @@ -291,7 +317,7 @@ export default function InvestissementFormModal({ Étape {step} : {stepTitles[step - 1]}
- {[1, 2, 3].map(n => ( + {Array.from({ length: totalSteps }, (_, i) => i + 1).map(n => (
)} - {/* ══ Étape 3 : Catégories, secteurs, autres ══ */} - {step === 3 && (<> + {/* ══ Étape 3 (si frais) : Frais liés à la plateforme ══ */} + {step === 3 && showFraisStep && (<> +
+

+ {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."} +

+
+
+ + +
+
+ + onFieldChange('frais_valeur', e.target.value)} + placeholder="0" /> +
+
+ + +
+ {form.frais_mode_reglement === 'compte_courant' && ( +
+ + {comptesInvestisseur.length > 0 ? ( + + ) : ( +

+ Aucun compte défini pour ce détenteur. Créer un compte → +

+ )} +
+ )} + {form.frais_mode_reglement === 'source' && form.frais_valeur !== '' && Number(form.frais_valeur) > 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))}`} +

+
+ )} + )} + + {/* ══ Étape suivante : Catégories, secteurs, autres ══ */} + {step === (showFraisStep ? 4 : 3) && (<>