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

+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,
);