This commit is contained in:
ocroguennec committed 2026-09-19 14:37:02 +02:00
1 parent 6bba34396c
commit 1d5737703b
9 files changed
+347 -45

No files matched your search

+27 -7
View File
@@ -144,13 +144,28 @@ router.get('/', (req, res) => {
// (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).
// Inclut désormais aussi les frais PE (investissement_pe_id) — jusqu'ici absents (LEFT JOIN
// ajouté le 19/09/26, bug trouvé par Claude en creusant le signalement Olivier du même jour
// sur le solde indicatif : une plateforme partagée crowdlending/PE voyait son solde_portefeuille
// légèrement surestimé dès qu'un frais PE "source"/"portefeuille" existait, ce frais n'étant
// jamais déduit nulle part). COALESCE sur investisseur_id : un frais n'a jamais les deux à la
// fois (CHECK en base), donc au plus un des deux LEFT JOIN matche réellement.
// Correctif du même jour (Olivier, après coup) : pour un deal PE, 'source' n'est PAS un débit
// supplémentaire — montant_investi (déjà déduit via capitalInvestiPePerPlat plus bas) est le
// montant BRUT transféré, frais compris. Ne compter un frais PE que s'il est 'portefeuille'
// (versement complémentaire, réellement distinct) ; côté crowdlending, 'source' reste compté
// (montant_investi y est net — cf. cas Fundora du 31/08/26, modèle différent de PE).
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')
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
${asOf ? 'AND f.date_operation <= ?' : ''}
GROUP BY f.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
@@ -381,13 +396,18 @@ router.get('/solde-historique', (req, res) => {
`).get(platId, ...invParams, date);
/* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant.
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant, même
// exclusion des frais PE 'source' (déjà inclus dans montant_investi, cf. commentaire ci-dessus).
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')
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE f.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
AND f.date_operation <= ?
`).get(platId, ...invParams, date);
+14 -3
View File
@@ -172,13 +172,24 @@ router.get('/', (req, res) => {
// Frais d'opération et corrections de solde — concepts crowdlending
// uniquement (cf. GET /api/dashboard), mais réduisent/ajustent le même
// porte-monnaie physique sur une plateforme partagée.
// Même correctif que dashboard.js (19/09/26) : le JOIN ne portait que sur les investissements
// crowdlending, donc les frais PE (investissement_pe_id) n'étaient jamais déduits du solde
// porte-monnaie — surestimation silencieuse sur toute plateforme partagée avec des frais PE
// "source"/"portefeuille". LEFT JOIN + COALESCE des deux investisseur_id possibles.
// Correctif du même jour (Olivier, après coup) : en fait seul 'portefeuille' doit compter côté
// PE — 'source' est déjà inclus dans montant_investi (le montant BRUT transféré à la
// souscription), contrairement au crowdlending où montant_investi est net (cf. dashboard.js).
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')
LEFT JOIN investissements i ON i.id = f.investissement_id
LEFT JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
WHERE ${invWhere.replace('investisseur_id', 'COALESCE(i.investisseur_id, ipe.investisseur_id)')}
AND (
(i.id IS NOT NULL AND f.mode_reglement IN ('source', 'portefeuille'))
OR (ipe.id IS NOT NULL AND f.mode_reglement = 'portefeuille')
)
${asOf ? 'AND f.date_operation <= ?' : ''}
GROUP BY f.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
+153
View File
@@ -181,6 +181,159 @@ router.get('/valorisations', (req, res) => {
res.json(rows);
});
/**
* GET /api/investissements-pe/mouvements-portefeuille — 19/09/26, demande Olivier : "le suivi
* de mouvement du porte-monnaie doit être commun [crowdlending + PE], avec la possibilité de
* distinguer si l'on parle de crowdlending ou de PE".
*
* Contexte du bug signalé : l'onglet "Mouvements Porte-monnaie" (Plateformes.jsx, calcul du
* solde indicatif ligne à ligne via MouvementsShared.computeSoldeIndicatifMap) ne consomme que
* des endpoints bornés au workspace actif (GET /remboursements, /depots-retraits,
* /frais-operations, /investissements) — cohérent pour séparer la performance crowdlending de
* la performance PE, mais faux pour le porte-monnaie : sur une plateforme partagée (ex.
* Fundora), l'argent est UN SEUL porte-monnaie physique chez le courtier, alimenté ou ponctionné
* indifféremment par les deux activités. Un remboursement PE réutilisé pour souscrire un deal
* crowdlending devient alors invisible du côté crowdlending, et le cumul plonge artificiellement
* dans le négatif après chaque retrait — exactement le symptôme observé sur Fundora (3,45 €).
*
* Même principe déjà retenu pour solde_portefeuille (dashboard.js / dashboardPe.js, walletMap,
* 17/09/26) : cet endpoint-ci fournit la partie du porte-monnaie qui vit HORS du workspace
* actuellement actif (typiquement PE, quand appelé depuis Plateformes.jsx en workspace
* crowdlending), pour que le frontend la fusionne avec ce qu'il a déjà côté workspace actif —
* sans double-compte, et sans avoir à assouplir le scope workspace_id des endpoints existants.
* Quatre familles de lignes, déjà mises en forme proche de MouvementsShared (type/montant/
* date_operation/plateforme_id/libelle) pour limiter le travail de fusion côté frontend :
* - souscription : capital déployé à la souscription de chaque deal PE (montant_investi),
* pas encore modélisé comme mouvement ailleurs (pendant PE de la ligne "souscription"
* synthétique que Plateformes.jsx génère déjà pour le crowdlending).
* - interets_plateforme : distributions PE (remboursements.type='interets_plateforme'),
* créditées pour leur net_recu — même convention que interetsPlateformeWalletPerPlat dans
* dashboardPe.js ("net_recu tient déjà compte des prélèvements").
* - frais : frais_operations rattachés à investissement_pe_id, jusqu'ici absents aussi du
* calcul officiel du solde (cf. correctif fraisPerPlat du même jour dans dashboard*.js).
* - depot / retrait : dépôts/retraits manuels (table depots_retraits) enregistrés alors que
* le workspace PE était actif — AJOUTÉ le 19/09/26 suite au signalement Olivier sur Fundora,
* où 4 dépôts manuels de 800 € (un par deal PE) étaient invisibles du porte-monnaie
* crowdlending, expliquant l'essentiel du solde indicatif faussé (bien plus que les 3,45 €
* de distribution initialement en cause). Filtrées sur workspace_id != workspace appelant
* (résolu comme à l'écriture, cf. resolveActiveWorkspaceId) plutôt que sur un id "PE" en
* dur, pour rester correct si un 3e workspace apparaît un jour. `raw_source` porte la valeur
* brute de depots_retraits.source (manuel/import_excel/auto_remboursement) pour que le
* frontend puisse reconstituer le badge "AUTO" comme pour les dépôts crowdlending.
*
* corrections_solde n'a PAS de colonne workspace_id (vérifié en base) : aucune correction ne
* peut donc être "perdue" par ce bug, ce n'était pas un vrai gap malgré ce qu'un premier passage
* avait supposé par prudence.
*/
router.get('/mouvements-portefeuille', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, invArgs, invCondDr, invArgsDr;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [userId];
invCondDr = 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invArgsDr = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'ipe.investisseur_id = ?';
invArgs = [id];
invCondDr = 'dr.investisseur_id = ?';
invArgsDr = [id];
}
// Volontairement PAS de filtre workspace_id sur souscriptions/distributions/frais (tables
// dédiées PE) : ce sont par construction des lignes qui vivent hors du workspace actif
// (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée
// entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas
// dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé.
const callerWorkspaceId = resolveActiveWorkspaceId(req);
const souscriptions = db.prepare(`
SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
ipe.date_souscription AS date_operation, ipe.montant_investi AS montant,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
`).all(...invArgs);
const distributions = db.prepare(`
SELECT r.id AS remb_id, ipe.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
r.date_remb AS date_operation, r.net_recu AS montant,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE r.type = 'interets_plateforme' AND ${invCond}
`).all(...invArgs);
const frais = db.prepare(`
SELECT f.id AS frais_id, f.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
f.date_operation, f.montant, f.mode_reglement,
ipe.nom_deal AS libelle, ipe.investisseur_id, inv.nom AS investisseur_nom
FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN plateformes p ON p.id = f.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
`).all(...invArgs);
const depots = db.prepare(`
SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom,
plat_inv.nom AS plateforme_detenteur_nom,
dr.date_operation, dr.type, dr.montant, dr.source AS raw_source,
dr.libelle, dr.investisseur_id, inv.nom AS investisseur_nom
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
JOIN investisseurs inv ON inv.id = dr.investisseur_id
WHERE dr.workspace_id != ? AND ${invCondDr}
`).all(callerWorkspaceId, ...invArgsDr);
const rows = [
...souscriptions.map(r => ({
id: `pe_sub_${r.deal_id}`, type: 'souscription', montant: r.montant,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...distributions.map(r => ({
id: `pe_remb_${r.remb_id}`, type: 'interets_plateforme', montant: r.montant,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...frais.map(r => ({
id: `pe_frais_${r.frais_id}`, type: 'frais', montant: r.montant, mode_reglement: r.mode_reglement,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
...depots.map(r => ({
id: `pe_dr_${r.dr_id}`, type: r.type, montant: r.montant, raw_source: r.raw_source,
date_operation: r.date_operation, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, libelle: r.libelle,
investisseur_id: r.investisseur_id, investisseur_nom: r.investisseur_nom,
})),
];
res.json(rows);
});
router.post('/', requireInvestisseur, (req, res, next) => {
try {
const body = Schema.parse(req.body);