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
+2401 -117

No files matched your search

+36 -3
View File
@@ -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
+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 { 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,
);
+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
* 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),
},
});
});
+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 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);
+30 -6
View File
@@ -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); }
});
+9 -1
View File
@@ -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