Files
crowdlending-app/backend/src/routes/remboursements.js
T

631 lines
32 KiB
JavaScript

import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js';
import { assertOwnedCompte } from './fraisOperations.js';
const router = Router();
const Schema = z.object({
type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme', 'interets_plateforme']).default('normal'),
investissement_id: z.number().int().positive().nullable().optional(),
bonus_plateforme_id: z.number().int().positive().nullable().optional(),
bonus_investisseur_id: z.number().int().positive().nullable().optional(),
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
// rattacher une distribution bonus_plateforme/interets_plateforme à un deal
// précis pour calculer son TVPI, quand plusieurs deals partagent une même
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
// Sans effet sur le type 'normal' (crowdlending).
investissement_pe_id: z.number().int().positive().nullable().optional(),
// Nature de la distribution pour un deal PE (18/09/26, demande Olivier ; étendue le 20/09/26,
// chantier "Fiscalité PE") : 'capital' = restitution du capital déployé (non imposable),
// 'interet_non_deploye' = intérêt versé sur le capital NON déployé (support obligataire du
// fonds, imposable comme un revenu), 'plus_value' = plus-value réalisée distribuée par le
// fonds au fil de ses propres cessions (imposable, mais reportée dans une case différente de
// 'interet_non_deploye' sur la déclaration — cf. taxreportPe.js) — cf. migration
// distribution_nature dans db/index.js. Optionnel : une distribution non classée reste comptée
// dans "Distributions" (pas "Obligation"/"Plus-value") côté PE. Sans effet hors type
// 'interets_plateforme'.
distribution_nature: z.enum(['capital', 'interet_non_deploye', 'plus_value']).nullable().optional(),
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
capital: z.number().nonnegative().default(0),
cashback: z.number().nonnegative().default(0),
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'),
notes: z.string().optional(),
methode_remboursement: z.enum(['portefeuille', 'compte_courant']).default('portefeuille'),
compte_id: z.number().int().positive().nullable().optional(),
}).refine(data => {
if (data.type === 'normal') return !!data.investissement_id;
return !!data.bonus_plateforme_id;
}, { message: 'investissement_id requis pour type normal ; bonus_plateforme_id requis pour les bonus' });
/**
* isTaxIndicatif = true → plateforme étrangère ou investissement exonéré PEA-PME :
* les prélèvements sont stockés à titre indicatif, mais le montant réellement
* versé (net_recu) n'en tient pas compte (capital + cashback + interets_bruts).
* isTaxIndicatif = false → plateforme flat_tax non exonérée :
* les prélèvements sont retenus à la source, net_recu = capital + cashback + interets_nets.
*/
function computeChamps(body, isTaxIndicatif = false) {
// 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) + baseNetteDeFrais) * 100) / 100
: Math.round(((body.capital || 0) + (body.cashback || 0) + interets_nets) * 100) / 100;
return { interets_nets, net_recu };
}
function getTaxIndicatif(body) {
// Intérêts plateforme : pas d'investissement, la fiscalité applicable est celle
// de la plateforme associée (bonus_plateforme_id) — pas de fiscalite_override possible.
if (body.type === 'interets_plateforme') {
if (!body.bonus_plateforme_id) return false;
const p = db.prepare('SELECT fiscalite FROM plateformes WHERE id = ?').get(body.bonus_plateforme_id);
return p ? p.fiscalite !== 'flat_tax' : false;
}
if (!body.investissement_id) return false;
const row = db.prepare(`
SELECT p.fiscalite, i.fiscalite_override
FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.id = ?
`).get(body.investissement_id);
if (!row) return false;
return row.fiscalite !== 'flat_tax' || row.fiscalite_override === 'exonere';
}
/**
* Crée ou supprime le retrait automatique lié à un remboursement.
* Appelé après chaque POST/PUT sur un remboursement normal.
* - Si methode === 'compte_courant' : supprime l'ancien retrait lié (s'il existe)
* puis en insère un nouveau avec source='auto_remboursement'.
* - Sinon : supprime simplement l'ancien retrait lié.
*/
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb, req) {
// Suppression du retrait automatique précédent (s'il y en avait un)
db.prepare('DELETE FROM depots_retraits WHERE remboursement_id = ?').run(rembId);
if (methode !== 'compte_courant') return;
const inv = db.prepare(
'SELECT investisseur_id, plateforme_id, nom_projet FROM investissements WHERE id = ?'
).get(investissementId);
if (!inv) return;
// Étape 2a du retrofit workspace_id (17/09/26) : le retrait auto généré par un
// remboursement hérite du workspace actif au moment du remboursement (même
// résolution que les dépôts/retraits manuels, cf. workspaceScope.js).
const workspaceId = resolveActiveWorkspaceId(req);
db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant,
libelle, source, remboursement_id, workspace_id)
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?, ?)
`).run(
inv.investisseur_id, inv.plateforme_id, dateRemb,
netRecu,
`Remboursement — ${inv.nom_projet}`,
rembId,
workspaceId,
);
}
/**
* 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);
if (!inv) return;
const { total_capital } = db.prepare(
'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id=?'
).get(investissement_id);
const { reinvTotal } = db.prepare(
'SELECT COALESCE(SUM(montant), 0) AS reinvTotal FROM reinvestissements WHERE investissement_id=?'
).get(investissement_id);
const capitalTotal = inv.montant_investi + (reinvTotal || 0);
const restant = Math.round((capitalTotal - total_capital) * 100) / 100;
const newStatut = restant <= 0 ? 'rembourse' : 'en_cours';
db.prepare(`
UPDATE investissements
SET statut = ?, updated_at = datetime('now')
WHERE id = ? AND statut IN ('en_cours', 'rembourse')
`).run(newStatut, investissement_id);
}
router.use(requireInvestisseur);
function assertOwnedInvestissement(invId, userId) {
const row = db.prepare(`
SELECT i.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');
}
function assertOwnedInvestisseur(invId, userId) {
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) throw new HttpError(403, 'Investisseur not in scope');
}
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const { from, to, plateforme_id, investissement_id } = req.query;
const invCond = scopeAll
? `(i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
OR (r.investissement_id IS NULL AND r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)))`
: `(i.investisseur_id = ?
OR (r.investissement_id IS NULL AND r.bonus_investisseur_id = ?))`;
const invArg = scopeAll ? req.user.id : req.investisseur.id;
// Étape 2c du retrofit workspace_id (17/09/26) : filtre par workspace actif,
// même principe que GET /depots-retraits.
const workspaceId = resolveActiveWorkspaceId(req);
const outerConds = [];
const outerArgs = [];
if (from) { outerConds.push('date_remb >= ?'); outerArgs.push(from); }
if (to) { outerConds.push('date_remb <= ?'); outerArgs.push(to); }
if (plateforme_id) { outerConds.push('plateforme_id = ?'); outerArgs.push(Number(plateforme_id)); }
if (investissement_id){ outerConds.push('investissement_id = ?'); outerArgs.push(Number(investissement_id)); }
const outerWhere = outerConds.length ? `WHERE ${outerConds.join(' AND ')}` : '';
const rows = db.prepare(`
WITH base AS (
SELECT r.*,
COALESCE(i.plateforme_id, r.bonus_plateforme_id) AS plateforme_id,
COALESCE(i.investisseur_id, r.bonus_investisseur_id) AS resolved_investisseur_id,
i.nom_projet AS inv_nom_projet,
CASE WHEN r.investissement_id IS NOT NULL
THEN i.montant_investi - (
SELECT COALESCE(SUM(r2.capital), 0)
FROM remboursements r2
WHERE r2.investissement_id = r.investissement_id
AND (r2.date_remb < r.date_remb OR (r2.date_remb = r.date_remb AND r2.id <= r.id))
)
ELSE 0
END AS capital_restant_du
FROM remboursements r
LEFT JOIN investissements i ON i.id = r.investissement_id
WHERE ${invCond} AND r.workspace_id = ?
)
SELECT
b.*,
CASE b.type
WHEN 'normal' THEN b.inv_nom_projet
WHEN 'bonus_parrainage' THEN '— Bonus Parrainage' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
WHEN 'bonus_plateforme' THEN '— Bonus Plateforme' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
WHEN 'interets_plateforme' THEN '— Intérêts plateforme' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
ELSE b.inv_nom_projet
END AS nom_projet,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
plat_inv.nom AS plateforme_detenteur_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
LEFT JOIN investisseurs inv ON inv.id = b.resolved_investisseur_id
LEFT JOIN comptes c ON c.id = b.compte_id
${outerWhere}
ORDER BY b.date_remb DESC, b.id DESC
`).all(invArg, invArg, workspaceId, ...outerArgs);
res.json(rows);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body));
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, frais_ttc, prelev_sociaux,
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id, workspace_id)
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.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,
resolveActiveWorkspaceId(req),
);
syncInvestissementStatut(body.investissement_id);
adjustSimulForActuals(db, body.investissement_id);
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
{
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(
'SELECT i.auto_reinvest, p.fiscalite FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id WHERE i.id = ?'
).get(body.investissement_id);
if (inv?.auto_reinvest) {
// Plateforme française (flat_tax) : utiliser les intérêts nets (prélevés à la source)
// Autres plateformes : utiliser les intérêts bruts (rien n'est retenu)
const montantAuto = inv.fiscalite === 'flat_tax' ? interets_nets : body.interets_bruts;
if (montantAuto > 0) {
db.prepare(`
INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source)
VALUES (?, ?, ?, 'Réinvestissement automatique des intérêts', 'auto')
`).run(body.investissement_id, montantAuto, body.date_remb);
generateSimulWithReinvestissements(db, body.investissement_id);
}
}
}
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
}
// Bonus (parrainage / plateforme) ou Intérêts plateforme
const bonusInvestisseurId = body.bonus_investisseur_id ?? req.investisseur.id;
assertOwnedInvestisseur(bonusInvestisseurId, req.user.id);
if (body.type === 'interets_plateforme') {
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, frais_ttc,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id,
investissement_pe_id, distribution_nature)
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.frais_ttc,
body.prelev_sociaux, body.prelev_forfaitaire,
interets_nets, net_recu, body.statut, body.notes || null,
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null, body.distribution_nature ?? null,
);
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
}
const r = db.prepare(`
INSERT INTO remboursements
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire,
interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?, ?, ?)
`).run(
body.type, body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
body.cashback, body.cashback, body.statut, body.notes || null,
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets: 0, net_recu: body.cashback });
} catch (e) { next(e); }
});
/* ── Retraitement fiscal en masse ───────────────────────────── */
router.post('/reprocess', (req, res, next) => {
try {
const round2 = v => Math.round(v * 100) / 100;
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.frais_ttc,
p.fiscalite, p.taux_fiscalite_locale,
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
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE r.type = 'normal'
AND r.investissement_id IS NOT NULL
AND inv.user_id = ?
`).all(req.user.id);
const pfuRates = db.prepare('SELECT * FROM taux_pfu').all();
const getRates = (dateStr) => {
const year = parseInt(dateStr.substring(0, 4), 10);
return pfuRates.find(r => r.annee === year) ?? null;
};
const stmtUpdate = db.prepare(`
UPDATE remboursements
SET taxe_locale=?, interets_bruts=?, prelev_sociaux=?, prelev_forfaitaire=?,
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=?
WHERE remboursement_id=? AND source='auto_remboursement'
`);
let updated = 0;
const affectedInvIds = new Set();
db.transaction(() => {
for (const r of rembs) {
const rates = getRates(r.date_remb);
let taxe_locale = 0;
let interets_bruts = r.interets_bruts;
let prelev_sociaux = 0;
let prelev_forfaitaire = 0;
if (r.fiscalite === 'avec_fiscalite_locale' && r.taux_fiscalite_locale) {
// Base = interets avant taxe locale (fallback sur interets_bruts si colonne vide)
const base = (r.interets_bruts_avant_local > 0)
? r.interets_bruts_avant_local
: r.interets_bruts;
taxe_locale = round2(base * r.taux_fiscalite_locale / 100);
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(baseNetteDeFrais * rates.prelev_sociaux / 100);
prelev_forfaitaire = round2(baseNetteDeFrais * rates.impot_revenu / 100);
}
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 (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) + baseNetteDeFrais)
: round2((r.capital || 0) + (r.cashback || 0) + interets_nets);
stmtUpdate.run(taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire,
interets_nets, net_recu, r.id);
stmtUpdateRetrait.run(net_recu, r.id);
affectedInvIds.add(r.investissement_id);
updated++;
}
})();
// Régénérer l'échéancier pour chaque investissement impacté
for (const invId of affectedInvIds) {
adjustSimulForActuals(db, invId);
}
res.json({ updated });
} catch (e) { next(e); }
});
/* ── Modifier un remboursement ──────────────────────────────── */
router.put('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare(`
SELECT r.id, r.type, r.investissement_id, r.bonus_plateforme_id, r.bonus_investisseur_id
FROM remboursements r
LEFT JOIN investissements i ON i.id = r.investissement_id
LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id)
WHERE r.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 { 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=?, 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.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,
id,
);
syncInvestissementStatut(body.investissement_id);
adjustSimulForActuals(db, body.investissement_id);
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
{
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=?, frais_ttc=?,
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
investissement_pe_id=?, distribution_nature=?
WHERE id=?
`).run(
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, body.investissement_pe_id ?? null,
body.distribution_nature ?? null, id,
);
} else {
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
db.prepare(`
UPDATE remboursements
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?, investissement_pe_id=?
WHERE id=?
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, body.investissement_pe_id ?? null, id);
}
res.json({ id, ...body, interets_nets, net_recu });
} catch (e) { next(e); }
});
/* ── Supprimer un remboursement ─────────────────────────────── */
router.delete('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare(`
SELECT r.id, r.investissement_id
FROM remboursements r
LEFT JOIN investissements i ON i.id = r.investissement_id
LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id)
WHERE r.id = ? AND inv.user_id = ?
`).get(id, req.user.id);
if (!existing) throw new HttpError(404, 'Not found');
syncAutoRetrait(id, 'portefeuille', 0, null, null, req); // supprime le retrait auto si présent
db.prepare('DELETE FROM remboursements WHERE id=?').run(id);
if (existing.investissement_id) {
syncInvestissementStatut(existing.investissement_id);
adjustSimulForActuals(db, existing.investissement_id);
}
res.json({ deleted: id });
} catch (e) { next(e); }
});
/* ── Supprimer tous les remboursements d'un investissement ───── */
router.delete('/', (req, res, next) => {
try {
const investissement_id = Number(req.query.investissement_id);
if (!investissement_id) throw new HttpError(400, 'investissement_id requis');
assertOwnedInvestissement(investissement_id, req.user.id);
const rembs = db.prepare('SELECT id FROM remboursements WHERE investissement_id=?').all(investissement_id);
for (const r of rembs) {
syncAutoRetrait(r.id, 'portefeuille', 0, null, null, req);
}
db.prepare('DELETE FROM remboursements WHERE investissement_id=?').run(investissement_id);
syncInvestissementStatut(investissement_id);
adjustSimulForActuals(db, investissement_id);
res.json({ deleted: rembs.length });
} catch (e) { next(e); }
});
// ── Backfill compte_id sur les remboursements ──────────────────────────────
// Pour chaque remboursement methode=compte_courant sans compte_id :
// 1. Utilise le compte_id de l'investissement lié si disponible
// 2. Sinon prend le premier compte de type compte_courant du détenteur
router.post('/backfill-comptes', (req, res, next) => {
try {
const rows = db.prepare(`
SELECT r.id AS remb_id, i.compte_id AS inv_compte_id, i.investisseur_id
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE r.methode_remboursement = 'compte_courant'
AND r.compte_id IS NULL
AND r.type = 'normal'
`).all(req.user.id);
let updated = 0;
const stmt = db.prepare('UPDATE remboursements SET compte_id=? WHERE id=?');
for (const row of rows) {
let compteId = row.inv_compte_id ?? null;
// Fallback : premier compte_courant du détenteur
if (!compteId) {
const compte = db.prepare(
"SELECT id FROM comptes WHERE investisseur_id=? AND user_id=? AND compte_reglement_par_defaut=1 ORDER BY id LIMIT 1"
).get(row.investisseur_id, req.user.id);
compteId = compte?.id ?? null;
}
if (compteId) {
stmt.run(compteId, row.remb_id);
updated++;
}
}
res.json({ updated, total: rows.length });
} catch (e) { next(e); }
});
export default router;