This commit is contained in:
ocroguennec committed 2026-09-26 11:15:26 +02:00
1 parent 14cf4e5a19
commit 26197fc411
15 files changed
+1776 -29

No files matched your search

+34
View File
@@ -3490,4 +3490,38 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissem
}
}
// Chantier "Fiscalité PE" (20/09/26, demande Olivier) ───────────────────────────────────────
//
// 1) Troisième nature de distribution : 'plus_value' (plus-value réalisée, distribuée par le
// fonds au fil des cessions de son portefeuille sous-jacent — cf. échange avec Olivier :
// contrairement à un compte-titres classique où l'on "vend" soi-même sa position en un seul
// événement, un fonds PE (FCPR/FPCI) restitue le produit de ses propres cessions par
// distributions successives). Choix : rattacher la plus-value réalisée aux distributions
// existantes plutôt que de construire un événement "cession" séparé (option écartée) — colle
// au fonctionnement réel et réutilise le mécanisme déjà en place (montant saisi dans
// interets_bruts, comme 'interet_non_deploye', cf. RembFormModal.jsx / RemboursementsSimple.jsx).
// Aucune migration de schéma nécessaire : distribution_nature est un TEXT libre sans CHECK
// (cf. commentaire ci-dessus), donc 'plus_value' est déjà une valeur valide en base — seule
// la validation Zod (remboursements.js) et les libellés frontend doivent la reconnaître.
//
// 2) Pertes définitives PE : jusqu'ici investissements_pe.statut acceptait déjà la valeur
// 'perte_definitive' (cf. investissementsPe.js, Zod), mais rien ne permettait d'en tracer le
// montant/la date/le motif pour la fiscalité — contrairement au crowdlending
// (investissement_pertes, case 2TY). Table analogue, même structure, adaptée à
// investissements_pe (pas de notion "ancien_statut" en_retard/procedure côté PE : un deal PE
// passe directement de 'valide'/'cloture' à 'perte_definitive', jamais via un statut
// intermédiaire de recouvrement).
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_pe_pertes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
date_effet TEXT NOT NULL,
montant_perte REAL NOT NULL CHECK(montant_perte >= 0),
ancien_statut TEXT,
motif TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_pe_pertes_inv ON investissement_pe_pertes(investissement_pe_id)');
export default db;
+91 -1
View File
@@ -48,6 +48,17 @@ const Schema = z.object({
mode_detention: z.enum(['direct', 'pea_pme', 'assurance_vie']).default('direct'),
});
// Déclaration d'une perte définitive de capital sur un deal PE (20/09/26, chantier
// "Fiscalité PE" — cf. migration investissement_pe_pertes dans db/index.js). Même schéma que
// PerteSchema côté crowdlending (investissements.js) : montant_perte éditable (perte partielle
// possible si une partie du capital a déjà été recouvrée), préfilli côté frontend mais non
// recalculé ici.
const PerteSchema = z.object({
date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
montant_perte: z.number().nonnegative(),
motif: z.string().trim().min(1, 'Le motif est obligatoire'),
});
const ValorisationSchema = z.object({
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
valorisation_nette: z.number().nonnegative(),
@@ -402,7 +413,18 @@ router.get('/:id', (req, res, next) => {
-- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
AS obligation_cumulee
AS obligation_cumulee,
-- Idem, pour la part "plus-value réalisée" des distributions (20/09/26, chantier
-- "Fiscalité PE" — cf. distribution_nature dans RembFormModal.jsx). Inclus dans
-- distributions_cumulees ET dans le TVPI, isolé ici pour l'affichage/le calcul fiscal.
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'plus_value'), 0)
AS plus_value_cumulee,
-- Idem, pour la part "restitution de capital déployé" — sert à préfiller le montant par
-- défaut de la modale "Déclarer une perte définitive" (capital pas encore restitué).
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'capital'), 0)
AS capital_restitue_cumule
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
@@ -412,6 +434,13 @@ router.get('/:id', (req, res, next) => {
row.tvpi = row.derniere_valorisation != null
? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100
: null;
// Pertes définitives (chantier "Fiscalité PE", 20/09/26) — au plus une ligne active à la
// fois en pratique (statut repasse à perte_definitive), mais l'historique complet
// (déclarations annulées comprises) est conservé pour traçabilité, comme
// investissement_pertes côté crowdlending.
row.pertes = db.prepare(
'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id ASC'
).all(id);
res.json(row);
} catch (e) { next(e); }
});
@@ -547,6 +576,67 @@ router.delete('/:id', requireInvestisseur, (req, res, next) => {
} catch (e) { next(e); }
});
/* ── Perte définitive (chantier "Fiscalité PE", 20/09/26) ───────
* POST /:id/perte-definitive — déclare un deal en perte définitive (capital réellement
* irrécouvrable : fonds liquidé sans distribution, société sous-jacente radiée…).
* Contrairement au crowdlending, un deal PE n'a pas de statut intermédiaire de
* recouvrement (en_retard/procedure) : n'importe quel statut autre que
* 'perte_definitive' peut y transiter directement.
* DELETE /:id/perte-definitive — annule la dernière déclaration, restaure le statut précédent.
*/
router.post('/:id/perte-definitive', requireInvestisseur, (req, res, next) => {
try {
const body = PerteSchema.parse(req.body);
const id = Number(req.params.id);
const deal = ownedDeal(req, id);
if (deal.statut === 'perte_definitive') {
throw new HttpError(400, 'Ce deal est déjà déclaré en perte définitive.');
}
let perteId;
const tx = db.transaction(() => {
const r = db.prepare(`
INSERT INTO investissement_pe_pertes (investissement_pe_id, date_effet, montant_perte, ancien_statut, motif)
VALUES (?,?,?,?,?)
`).run(id, body.date_effet, body.montant_perte, deal.statut, body.motif);
perteId = r.lastInsertRowid;
db.prepare(`
UPDATE investissements_pe SET statut = 'perte_definitive', updated_at = datetime('now') WHERE id = ?
`).run(id);
});
tx();
const perte = db.prepare('SELECT * FROM investissement_pe_pertes WHERE id = ?').get(perteId);
res.status(201).json(perte);
} catch (e) { next(e); }
});
router.delete('/:id/perte-definitive', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
const deal = ownedDeal(req, id);
if (deal.statut !== 'perte_definitive') {
throw new HttpError(400, "Ce deal n'est pas déclaré en perte définitive.");
}
const derniere = db.prepare(
'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id DESC LIMIT 1'
).get(id);
const statutRestaure = derniere?.ancien_statut || 'valide';
const tx = db.transaction(() => {
if (derniere) db.prepare('DELETE FROM investissement_pe_pertes WHERE id = ?').run(derniere.id);
db.prepare(`
UPDATE investissements_pe SET statut = ?, updated_at = datetime('now') WHERE id = ?
`).run(statutRestaure, id);
});
tx();
res.status(204).end();
} catch (e) { next(e); }
});
/* ── Historique des valorisations ────────────────────────────── */
router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => {
+10 -6
View File
@@ -20,12 +20,16 @@ const Schema = z.object({
// 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) : 'capital' = restitution
// du capital déployé, 'interet_non_deploye' = intérêt versé sur le capital NON déployé (support
// obligataire du fonds) — cf. migration distribution_nature dans db/index.js. Optionnel : une
// distribution non classée reste comptée dans "Distributions" (pas "Obligation") côté PE. Sans
// effet hors type 'interets_plateforme'.
distribution_nature: z.enum(['capital', 'interet_non_deploye']).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),
+434
View File
@@ -0,0 +1,434 @@
import { Router } from 'express';
import db from '../db/index.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
const router = Router();
const round2 = v => Math.round((v ?? 0) * 100) / 100;
const esc = v => String(v ?? '').replace(/;/g, ',').replace(/[\r\n]+/g, ' ');
/**
* /api/taxreport-pe — Fiscalité Private Equity (chantier du 20/09/26, demande Olivier :
* "Nous n'avons pas fait la partie Fiscalité pour les PE").
*
* Contrairement au crowdlending (taxreport.js), où chaque remboursement porte déjà ses
* propres prélev_sociaux/prelev_forfaitaire calculés à partir de la fiscalité de la
* plateforme, la fiscalité PE dépend avant tout du **mode de détention** du deal
* (investissements_pe.mode_detention — direct / PEA-PME / assurance-vie), trois régimes
* fiscaux très différents entre eux (choix explicite d'Olivier : "j'ai besoin de
* différencier les 3"). Cette route agrège donc les mêmes montants que
* RemboursementsSimple.jsx / InvestissementPeDetail.jsx, mais groupés par mode_detention
* plutôt que par plateforme.
*
* Décisions de conception validées avec Olivier avant implémentation :
* - La plus-value réalisée n'est PAS un événement "cession" séparé : elle est portée par
* les distributions existantes (remboursements.distribution_nature = 'plus_value'),
* au même titre que 'capital' (restitution non imposable) et 'interet_non_deploye'
* (revenu imposable sur le capital non déployé) — cf. migration dans db/index.js.
* - Les pertes définitives (investissement_pe_pertes, cf. investissementsPe.js
* POST/DELETE /:id/perte-definitive) sont reportées par mode_detention mais jamais
* imputées automatiquement sur les plus-values : comme taxreport.js côté crowdlending,
* cette route reste indicative — c'est à l'utilisateur d'arbitrer l'imputation réelle
* sur sa déclaration.
* - direct : PFU (12,8 % IR + 17,2 % PS, taux annuels dans taux_pfu) appliqué séparément
* sur la plus-value réalisée (case 3VG, formulaire 2074) et sur l'intérêt non déployé
* (case 2TR, revenus de capitaux mobiliers) — même taux global, mais deux cases
* différentes sur la déclaration, donc jamais fusionnées dans un seul montant.
* - PEA-PME : seuls les prélèvements sociaux (17,2 %) sont calculés — l'impôt sur le
* revenu est nativement exonéré tant que les conditions du plan sont respectées
* (retrait après 5 ans). Pas de case précise : en pratique, l'établissement teneur du
* PEA-PME précompte lui-même les PS au retrait ; ce montant reste indicatif pour
* vérification.
* - assurance-vie : aucun taux n'est appliqué automatiquement — la fiscalité dépend de
* l'ancienneté du contrat et du cumul des primes versées (au-delà de 150 000 €),
* des éléments que l'application ne connaît pas. Seuls les montants bruts sont
* exposés, à traiter avec votre assureur/expert fiscal.
*
* Comme taxreport.js : "Les cases sont indicatives. Vérifiez avec votre situation fiscale
* réelle." — cette route effectue des calculs déterministes à partir de règles convenues
* avec Olivier, ce n'est pas un avis fiscal.
*/
const MODES = ['direct', 'pea_pme', 'assurance_vie'];
function resolveScope(req, res) {
const scopeAll = req.query.scope === 'all';
if (scopeAll) return { invCond: 'inv.user_id = ?', invArgs: [req.user.id] };
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) { res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); return null; }
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, req.user.id);
if (!row) { res.status(403).json({ error: 'Investisseur not found or not owned by user' }); return null; }
return { invCond: 'inv.id = ?', invArgs: [id] };
}
function emptyBucket() {
return {
plus_value_realisee: 0,
interet_non_deploye: 0,
capital_restitue: 0,
non_classe: 0,
pertes_definitives: 0,
frais_gestion: 0,
};
}
// GET /years — années disponibles pour le sélecteur (mêmes sources que GET /), même principe
// que GET /taxreport/years côté crowdlending.
router.get('/years', (req, res) => {
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT DISTINCT strftime('%Y', r.date_remb) AS annee
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ? AND r.type = 'interets_plateforme'
UNION
SELECT DISTINCT strftime('%Y', ip.date_effet) AS annee
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY annee DESC
`).all(...invArgs, workspaceId, ...invArgs, workspaceId);
const years = rows.map(r => r.annee).filter(Boolean);
const currentYear = new Date().getFullYear();
if (!years.includes(String(currentYear))) years.unshift(String(currentYear));
if (!years.includes(String(currentYear - 1))) years.push(String(currentYear - 1));
res.json([...new Set(years)].sort((a, b) => b.localeCompare(a)));
});
router.get('/', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
// Distributions (remboursements de type interets_plateforme, rattachées à un deal PE précis
// — seul cas où mode_detention est connu). Les distributions non rattachées à un deal
// (investissement_pe_id NULL) sont regroupées à part : leur régime fiscal est indéterminable.
const distribRows = db.prepare(`
SELECT ipe.mode_detention, r.distribution_nature, r.net_recu
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
`).all(...invArgs, workspaceId, annee);
const distribNonRattacheesRows = db.prepare(`
SELECT r.distribution_nature, r.net_recu
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND r.investissement_pe_id IS NULL
AND r.workspace_id = ?
`).all(...invArgs, annee, workspaceId);
// Pertes définitives par mode de détention.
const pertesRows = db.prepare(`
SELECT ipe.mode_detention, ip.montant_perte, ip.date_effet, ip.motif,
ipe.nom_deal, p.nom AS plateforme_nom
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
JOIN plateformes p ON p.id = ipe.plateforme_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(ip.date_effet,1,4) = ?
`).all(...invArgs, workspaceId, annee);
// Frais de gestion (informatifs — pas de déduction automatique appliquée, la déductibilité
// des frais de gestion PE dépend de cas particuliers hors du périmètre de ce calcul).
const fraisRows = db.prepare(`
SELECT ipe.mode_detention, f.montant
FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(f.date_operation,1,4) = ?
AND f.categorie = 'gestion'
`).all(...invArgs, workspaceId, annee);
const buckets = { direct: emptyBucket(), pea_pme: emptyBucket(), assurance_vie: emptyBucket() };
const nonRattachees = emptyBucket();
const addDistrib = (bucket, nature, montant) => {
if (nature === 'plus_value') bucket.plus_value_realisee += montant;
else if (nature === 'interet_non_deploye') bucket.interet_non_deploye += montant;
else if (nature === 'capital') bucket.capital_restitue += montant;
else bucket.non_classe += montant;
};
for (const r of distribRows) {
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
addDistrib(bucket, r.distribution_nature, r.net_recu || 0);
}
for (const r of distribNonRattacheesRows) {
addDistrib(nonRattachees, r.distribution_nature, r.net_recu || 0);
}
for (const r of pertesRows) {
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
bucket.pertes_definitives += r.montant_perte || 0;
}
for (const r of fraisRows) {
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
bucket.frais_gestion += r.montant || 0;
}
// Taux PFU de l'année (mêmes taux que le crowdlending, table partagée taux_pfu).
const pfuRates = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(Number(annee))
?? db.prepare('SELECT * FROM taux_pfu ORDER BY annee DESC LIMIT 1').get()
?? null;
const fiscal = {};
for (const mode of MODES) {
const b = buckets[mode];
if (mode === 'direct') {
fiscal[mode] = {
prelev_sociaux_plus_value: pfuRates ? round2(b.plus_value_realisee * pfuRates.prelev_sociaux / 100) : null,
prelev_forfaitaire_plus_value: pfuRates ? round2(b.plus_value_realisee * pfuRates.impot_revenu / 100) : null,
prelev_sociaux_interets: pfuRates ? round2(b.interet_non_deploye * pfuRates.prelev_sociaux / 100) : null,
prelev_forfaitaire_interets: pfuRates ? round2(b.interet_non_deploye * pfuRates.impot_revenu / 100) : null,
};
} else if (mode === 'pea_pme') {
const baseImposable = b.plus_value_realisee + b.interet_non_deploye;
fiscal[mode] = {
prelev_sociaux: pfuRates ? round2(baseImposable * pfuRates.prelev_sociaux / 100) : null,
note: "Impôt sur le revenu nativement exonéré si les conditions du PEA-PME sont respectées (retrait après 5 ans) — seuls les prélèvements sociaux sont indicatifs ici.",
};
} else {
fiscal[mode] = {
note: "Fiscalité dépendant de l'ancienneté du contrat et du cumul des primes versées — aucun taux n'est appliqué automatiquement. Vérifiez avec votre assureur ou votre expert fiscal.",
};
}
}
// Important (correction 20/09/26, après vérification des libellés IFU réels dans
// Cerfa2561Preview.jsx côté crowdlending) : une perte définitive sur un deal PE n'est PAS
// une "perte en capital sur prêt participatif" (case 2TY du 2042, régime spécifique à l'art.
// 125-00 A du CGI réservé aux prêts de financement participatif — cf. KS/2TY dans
// Cerfa2561Preview.jsx, jamais utilisé pour un produit "à revenu fixe" ordinaire). Un deal PE
// est une part de fonds (valeur mobilière), donc sa perte définitive est une MOINS-VALUE DE
// CESSION (cession à prix nul), qui relève du même formulaire 2074 que la plus-value réalisée
// (imputable uniquement sur des plus-values mobilières, pas sur le revenu global) — jamais de
// la famille "2TR / IFU". D'où case_3VH_direct (et non case_2TY_direct) ci-dessous.
const cases = {
case_3VG_direct: round2(buckets.direct.plus_value_realisee),
case_2TR_direct: round2(buckets.direct.interet_non_deploye),
case_3VH_direct: round2(buckets.direct.pertes_definitives),
pea_pme_base_imposable_ps: round2(buckets.pea_pme.plus_value_realisee + buckets.pea_pme.interet_non_deploye),
pea_pme_pertes: round2(buckets.pea_pme.pertes_definitives),
assurance_vie_brut: round2(buckets.assurance_vie.plus_value_realisee + buckets.assurance_vie.interet_non_deploye),
assurance_vie_pertes: round2(buckets.assurance_vie.pertes_definitives),
note: "Les cases sont indicatives. Vérifiez avec votre situation fiscale réelle.",
};
res.json({
annee,
par_mode_detention: {
direct: { ...round2Bucket(buckets.direct), fiscal: fiscal.direct },
pea_pme: { ...round2Bucket(buckets.pea_pme), fiscal: fiscal.pea_pme },
assurance_vie: { ...round2Bucket(buckets.assurance_vie), fiscal: fiscal.assurance_vie },
},
non_rattachees: round2Bucket(nonRattachees),
pertes_detail: pertesRows.map(r => ({ ...r, montant_perte: round2(r.montant_perte) })),
has_pfu_rates: !!pfuRates,
cases,
});
});
function round2Bucket(b) {
return {
plus_value_realisee: round2(b.plus_value_realisee),
interet_non_deploye: round2(b.interet_non_deploye),
capital_restitue: round2(b.capital_restitue),
non_classe: round2(b.non_classe),
pertes_definitives: round2(b.pertes_definitives),
frais_gestion: round2(b.frais_gestion),
};
}
router.get('/export', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT ipe.nom_deal, p.nom AS plateforme, ipe.mode_detention,
r.date_remb, r.distribution_nature, r.net_recu, r.statut
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(r.date_remb,1,4) = ?
AND r.type = 'interets_plateforme'
ORDER BY r.date_remb
`).all(...invArgs, workspaceId, annee);
const pertesRows = db.prepare(`
SELECT ipe.nom_deal, p.nom AS plateforme, ipe.mode_detention,
ip.date_effet AS date_remb, 'perte_definitive' AS distribution_nature,
ip.montant_perte AS net_recu, 'perte' AS statut
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND substr(ip.date_effet,1,4) = ?
`).all(...invArgs, workspaceId, annee);
const header = ['Deal','Plateforme','Mode de detention','Date','Nature','Montant net','Statut'];
const allRows = [...rows, ...pertesRows].sort((a, b) => (a.date_remb || '').localeCompare(b.date_remb || ''));
const csv = [
header.join(';'),
...allRows.map(r => [
esc(r.nom_deal), esc(r.plateforme), esc(r.mode_detention),
r.date_remb, esc(r.distribution_nature), r.net_recu, r.statut,
].join(';')),
].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="fiscalite-pe-${annee}.csv"`);
res.send('\uFEFF' + csv);
});
// ── CERFA 2042 / 2561 (IFU) — deals PE en détention directe ────────────────────────────────
//
// Demande d'Olivier (20/09/26) : reprendre, pour les deals PE en mode de détention 'direct'
// (compte-titres, seul régime où un PFU complet s'applique — cf. RegimeCard 'direct' dans
// TaxReportPe.jsx), le même principe que les onglets "CERFA 2042" et "CERFA 2561 (IFU)"
// crowdlending (Cerfa2042Preview.jsx / Cerfa2561Preview.jsx, taxreport.js::buildCerfa2561) :
// un suivi mensuel par plateforme × détenteur, et un mockup des cases de l'IFU réel.
//
// Différence structurelle importante avec le crowdlending (cf. recherche du 20/09/26 dans
// Cerfa2561Preview.jsx, aucune case "cessions de valeurs mobilières" n'y existe) : l'IFU ne
// couvre que les "produits de placement à revenu fixe" (case AR → 2TR), jamais les plus-values
// de cession. Une distribution PE de nature 'interet_non_deploye' relève donc de cette même
// case AR/2TR (jamais KR/2TT, réservée aux prêts de financement participatif — un deal PE est
// une part de fonds, pas un prêt) ; une distribution de nature 'plus_value' est HORS PÉRIMÈTRE
// IFU (formulaire 2074 → case 3VG du 2042), affichée séparément plutôt que mélangée à la case
// 2TR. Une perte définitive PE est, de la même façon, une moins-value de cession (formulaire
// 2074 → case 3VH), jamais la case 2TY (spécifique aux prêts participatifs, cf. KS/2TY dans
// Cerfa2561Preview.jsx) — cf. commentaire sur cases.case_3VH_direct plus haut dans ce fichier.
router.get('/cerfa2561', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scope = resolveScope(req, res);
if (!scope) return;
const { invCond, invArgs } = scope;
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT
p.id AS plateforme_id,
p.nom AS plateforme_nom,
inv.id AS investisseur_id,
inv.nom AS investisseur_nom,
inv.prenom AS investisseur_prenom,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.net_recu ELSE 0 END), 0) AS interets_bruts,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.prelev_sociaux ELSE 0 END), 0) AS prelev_sociaux,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.prelev_forfaitaire ELSE 0 END), 0) AS prelev_forfaitaire,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.net_recu ELSE 0 END), 0) AS plus_values,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.prelev_sociaux ELSE 0 END), 0) AS pv_prelev_sociaux,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.prelev_forfaitaire ELSE 0 END), 0) AS pv_prelev_forfaitaire
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND ipe.mode_detention = 'direct'
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
GROUP BY p.id, inv.id
ORDER BY p.nom, inv.nom
`).all(...invArgs, workspaceId, annee);
const pertesRows = db.prepare(`
SELECT p.id AS plateforme_id, ipe.investisseur_id,
COALESCE(SUM(ip.montant_perte), 0) AS perte
FROM investissement_pe_pertes ip
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND ipe.mode_detention = 'direct'
AND substr(ip.date_effet,1,4) = ?
GROUP BY p.id, ipe.investisseur_id
`).all(...invArgs, workspaceId, annee);
const pertesMap = {};
for (const p of pertesRows) pertesMap[`${p.plateforme_id}_${p.investisseur_id}`] = p.perte;
const lignes = rows.map(r => {
const key = `${r.plateforme_id}_${r.investisseur_id}`;
return {
plateforme_id: r.plateforme_id,
plateforme_nom: r.plateforme_nom,
investisseur_id: r.investisseur_id,
investisseur_nom: r.investisseur_nom,
investisseur_prenom: r.investisseur_prenom,
interets_bruts: round2(r.interets_bruts),
prelev_sociaux: round2(r.prelev_sociaux),
prelev_forfaitaire: round2(r.prelev_forfaitaire),
plus_values: round2(r.plus_values),
pv_prelev_sociaux: round2(r.pv_prelev_sociaux),
pv_prelev_forfaitaire: round2(r.pv_prelev_forfaitaire),
// Cases IFU (cf. commentaire au-dessus de la route) :
case_2TR: Math.round(r.interets_bruts),
case_2BH: r.prelev_sociaux > 0 ? Math.round(r.interets_bruts) : 0,
case_2CK: Math.round(r.prelev_forfaitaire),
// Hors IFU — reportées séparément (2074), jamais fusionnées à 2TR/2TY.
case_3VG: Math.round(r.plus_values),
case_3VH: Math.round(pertesMap[key] ?? 0),
};
});
const moisRows = db.prepare(`
SELECT p.id AS plateforme_id, ipe.investisseur_id, substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.net_recu ELSE 0 END), 0) AS interets_bruts,
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.net_recu ELSE 0 END), 0) AS plus_values
FROM remboursements r
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond}
AND ipe.workspace_id = ?
AND ipe.mode_detention = 'direct'
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
GROUP BY p.id, ipe.investisseur_id, mois
`).all(...invArgs, workspaceId, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = { interets_bruts: round2(m.interets_bruts), plus_values: round2(m.plus_values) };
}
res.json({ annee, lignes: lignes.map(l => ({ ...l, mois: moisMap[`${l.plateforme_id}_${l.investisseur_id}`] ?? {} })) });
});
export default router;
+2
View File
@@ -21,6 +21,7 @@ import simulRouter from './routes/simul.js';
import dashboardRouter from './routes/dashboard.js';
import dashboardPeRouter from './routes/dashboardPe.js';
import taxreportRouter from './routes/taxreport.js';
import taxreportPeRouter from './routes/taxreportPe.js';
import platefomeTaxRouter from './routes/plateforme-tax.js';
import importsRouter from './routes/imports.js';
import pfuRouter from './routes/pfu.js';
@@ -143,6 +144,7 @@ app.use('/api/simul', requireAuth, simulRouter);
app.use('/api/dashboard', requireAuth, dashboardRouter);
app.use('/api/dashboard-pe', requireAuth, dashboardPeRouter);
app.use('/api/taxreport', requireAuth, taxreportRouter);
app.use('/api/taxreport-pe', requireAuth, taxreportPeRouter);
app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter);
app.use('/api/imports', requireAuth, importsRouter);
app.use('/api/pfu', requireAuth, pfuRouter);