Mise en place de la feature de gestion des Frais
This commit is contained in:
1 parent
9ea078302d
commit
0334a63139
27 files changed
+2401
-117
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* frais_operations — traçabilité des frais facturés par certaines plateformes
|
||||
* (frais d'entrée, frais de gestion, frais sur distribution), qu'ils soient
|
||||
* prélevés "à la source" (aucun mouvement de trésorerie réel — le montant
|
||||
* est simplement documenté à titre de traçabilité) ou payés en "versement
|
||||
* complémentaire" via le porte-monnaie de la plateforme ou un compte courant
|
||||
* du détenteur (dans ce dernier cas, sans impact sur le solde du porte-monnaie).
|
||||
*
|
||||
* Montant stocké : TTC uniquement (pas de détail HT/TVA, non pertinent pour
|
||||
* un particulier — cf. échange avec Olivier, 30/08/26).
|
||||
*
|
||||
* Étape 1 (fondations) : ce module fournit un CRUD complet, mais rien dans
|
||||
* l'application ne crée encore de frais_operations automatiquement — la
|
||||
* saisie à la souscription, la modale dédiée et l'intégration aux Mouvements
|
||||
* viendront dans une prochaine passe.
|
||||
*/
|
||||
|
||||
const Schema = z.object({
|
||||
investissement_id: z.number().int().positive(),
|
||||
remboursement_id: z.number().int().positive().nullable().optional(),
|
||||
montant: z.number().positive(),
|
||||
mode_reglement: z.enum(['source', 'portefeuille', 'compte_courant']).default('source'),
|
||||
compte_id: z.number().int().positive().nullable().optional(),
|
||||
date_operation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
origine: z.enum(['souscription', 'remboursement', 'manuel']).default('manuel'),
|
||||
// Classement visible côté utilisateur (31/08/26, demande Olivier) — distinct de `origine`
|
||||
// (qui reste un détail technique de création). 'souscription' est réservée à l'étape
|
||||
// "Frais" du formulaire d'investissement (unique par investissement, cf. assertUniqueSouscription).
|
||||
categorie: z.enum(['souscription', 'gestion', 'distribution', 'autre']).default('autre'),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
function assertOwnedInvestissement(invId, userId) {
|
||||
const row = db.prepare(`
|
||||
SELECT i.id, i.plateforme_id FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
||||
WHERE i.id = ?
|
||||
`).get(userId, invId);
|
||||
if (!row) throw new HttpError(403, 'Investissement not in scope');
|
||||
return row;
|
||||
}
|
||||
|
||||
function assertRemboursementBelongs(rembId, invId) {
|
||||
if (!rembId) return;
|
||||
const remb = db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?')
|
||||
.get(rembId, invId);
|
||||
if (!remb) throw new HttpError(400, "remboursement_id ne correspond pas à cet investissement");
|
||||
}
|
||||
|
||||
export function assertOwnedCompte(compteId, userId) {
|
||||
if (!compteId) return;
|
||||
const compte = db.prepare('SELECT id FROM comptes WHERE id = ? AND user_id = ?').get(compteId, userId);
|
||||
if (!compte) throw new HttpError(403, 'Compte inconnu ou non autorisé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un investissement ne peut avoir qu'UNE seule entrée de catégorie 'souscription' — c'est
|
||||
* l'entrée lue/écrite depuis l'étape "Frais" du formulaire de création/modification de
|
||||
* l'investissement (jamais depuis le bloc "Frais d'opération" de la fiche, qui exclut cette
|
||||
* catégorie de son sélecteur). `excludeId` : à passer lors d'un PUT pour ne pas se bloquer
|
||||
* soi-même.
|
||||
*/
|
||||
function assertUniqueSouscription(investissementId, categorie, excludeId) {
|
||||
if (categorie !== 'souscription') return;
|
||||
const existing = excludeId
|
||||
? db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription' AND id != ?`).get(investissementId, excludeId)
|
||||
: db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription'`).get(investissementId);
|
||||
if (existing) throw new HttpError(409, 'Un frais de catégorie "souscription" existe déjà pour cet investissement');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/frais-operations?investissement_id=123
|
||||
* Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs).
|
||||
*/
|
||||
router.get('/', (req, res, next) => {
|
||||
try {
|
||||
const { investissement_id } = req.query;
|
||||
let rows;
|
||||
if (investissement_id) {
|
||||
assertOwnedInvestissement(Number(investissement_id), req.user.id);
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom
|
||||
FROM frais_operations f
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE f.investissement_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(Number(investissement_id));
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom
|
||||
FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE inv.user_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id);
|
||||
}
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie);
|
||||
|
||||
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO frais_operations
|
||||
(investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
);
|
||||
res.status(201).json({ id: r.lastInsertRowid, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.prepare(`
|
||||
SELECT f.id FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE f.id = ? AND inv.user_id = ?
|
||||
`).get(id, req.user.id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
|
||||
const body = Schema.parse(req.body);
|
||||
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie, id);
|
||||
|
||||
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE frais_operations
|
||||
SET investissement_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
|
||||
compte_id=?, date_operation=?, origine=?, categorie=?, notes=?, updated_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(
|
||||
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
id,
|
||||
);
|
||||
res.json({ id, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.prepare(`
|
||||
SELECT f.id FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE f.id = ? AND inv.user_id = ?
|
||||
`).get(id, req.user.id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
|
||||
db.prepare('DELETE FROM frais_operations WHERE id = ?').run(id);
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,6 +4,7 @@ import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { 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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../db/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /frais-operations:
|
||||
* get:
|
||||
* summary: Liste des frais liés aux investissements (entrée, gestion, distribution)
|
||||
* description: >
|
||||
* Frais facturés par certaines plateformes en dehors des intérêts/prélèvements fiscaux
|
||||
* habituels : frais d'entrée à la souscription, frais de gestion, frais sur distribution
|
||||
* (retenus ou non sur un remboursement), ou autre. `mode_reglement` indique comment le
|
||||
* frais a été réglé — "source" : aucun mouvement de trésorerie réel, montant purement
|
||||
* indicatif (déjà déduit par la plateforme en amont) ; "portefeuille" : débité du
|
||||
* porte-monnaie de la plateforme ; "compte_courant" : débité d'un compte courant du
|
||||
* détenteur (`compte_nom`), sans impact sur le porte-monnaie ; "remboursement" : retenu
|
||||
* directement sur le remboursement lié (`remboursement_id`), déjà pris en compte dans les
|
||||
* champs `interets_nets`/`net_recu` de ce remboursement — ne pas le compter une seconde
|
||||
* fois. Seuls les modes "portefeuille" et "compte_courant" représentent une sortie
|
||||
* d'argent réelle et séparée (c'est ce sous-ensemble qui est déduit de
|
||||
* `gain_net_depuis_debut` sur /dashboard).
|
||||
* tags: [Frais]
|
||||
* security: [{ ApiKeyAuth: [] }]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: date_debut
|
||||
* schema: { type: string, format: date }
|
||||
* - in: query
|
||||
* name: date_fin
|
||||
* schema: { type: string, format: date }
|
||||
* responses:
|
||||
* 200: { description: Liste des frais }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const { date_debut, date_fin } = req.query;
|
||||
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
|
||||
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
|
||||
const conds = [req.investisseurScopeAll
|
||||
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
|
||||
: 'i.investisseur_id = ?'];
|
||||
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
|
||||
if (date_debut) { conds.push('f.date_operation >= ?'); args.push(date_debut); }
|
||||
if (date_fin) { conds.push('f.date_operation <= ?'); args.push(date_fin); }
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT f.id, i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
|
||||
f.montant, f.mode_reglement, f.categorie, f.date_operation,
|
||||
f.remboursement_id, c.nom AS compte_nom, f.notes
|
||||
FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN plateformes p ON p.id = i.plateforme_id
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE ${conds.join(' AND ')}
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(...args);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -3,6 +3,7 @@ import investisseurRouter from './investisseur.js';
|
||||
import investissementsRouter from './investissements.js';
|
||||
import 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);
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user