MAJ
This commit is contained in:
1 parent
6bba34396c
commit
1d5737703b
9 files changed
+347
-45
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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] : []));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user