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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user