From 26197fc411e1d263955ab355a973383af0837fac Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 26 Sep 2026 11:15:26 +0200 Subject: [PATCH] MAJ --- backend/src/db/index.js | 34 ++ backend/src/routes/investissementsPe.js | 92 +++- backend/src/routes/remboursements.js | 16 +- backend/src/routes/taxreportPe.js | 434 +++++++++++++++++ backend/src/server.js | 2 + frontend/src/App.jsx | 13 +- frontend/src/components/Cerfa2042Pe.jsx | 210 ++++++++ frontend/src/components/Cerfa2561Pe.jsx | 333 +++++++++++++ frontend/src/components/Layout.jsx | 12 +- frontend/src/components/RembFormModal.jsx | 4 + frontend/src/pages/InvestissementPeDetail.jsx | 146 +++++- frontend/src/pages/PlateformesPe.jsx | 5 +- frontend/src/pages/RemboursementsSimple.jsx | 33 +- frontend/src/pages/TaxReportPe.jsx | 451 ++++++++++++++++++ frontend/src/utils/format.js | 20 + 15 files changed, 1776 insertions(+), 29 deletions(-) create mode 100644 backend/src/routes/taxreportPe.js create mode 100644 frontend/src/components/Cerfa2042Pe.jsx create mode 100644 frontend/src/components/Cerfa2561Pe.jsx create mode 100644 frontend/src/pages/TaxReportPe.jsx diff --git a/backend/src/db/index.js b/backend/src/db/index.js index c0dbafe..e2d670f 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/routes/investissementsPe.js b/backend/src/routes/investissementsPe.js index 96c1f7f..fb997fe 100644 --- a/backend/src/routes/investissementsPe.js +++ b/backend/src/routes/investissementsPe.js @@ -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) => { diff --git a/backend/src/routes/remboursements.js b/backend/src/routes/remboursements.js index cf46a1d..5cbebd2 100644 --- a/backend/src/routes/remboursements.js +++ b/backend/src/routes/remboursements.js @@ -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), diff --git a/backend/src/routes/taxreportPe.js b/backend/src/routes/taxreportPe.js new file mode 100644 index 0000000..9ce6227 --- /dev/null +++ b/backend/src/routes/taxreportPe.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index d4e35eb..a4faf05 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af70788..e1f490e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,6 +23,7 @@ import Frais from './pages/Frais.jsx'; import FraisPe from './pages/FraisPe.jsx'; import SimulRemboursements from './pages/SimulRemboursements.jsx'; import TaxReport from './pages/TaxReport.jsx'; +import TaxReportPe from './pages/TaxReportPe.jsx'; import Settings from './pages/Settings.jsx'; import MonCompte from './pages/MonCompte.jsx'; import Admin from './pages/Admin.jsx'; @@ -164,6 +165,16 @@ function PlateformesRouter() { return ; } +// Même principe (chantier "Fiscalité PE", 20/09/26, demande Olivier : "Nous n'avons pas fait la +// partie Fiscalité pour les PE") : la page Fiscalité bascule sur TaxReportPe.jsx pour tout +// workspace autre que crowdlending — la route n'est donc plus réservée au crowdlending via +// RequireWorkspace (elle l'était jusqu'ici, cf. route "taxreport" plus bas). +function TaxReportRouter() { + const { active } = useWorkspace() || {}; + if (active && active.type !== 'crowdlending') return ; + return ; +} + export default function App() { const [allowRegistration, setAllowRegistration] = useState(true); @@ -215,7 +226,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Cerfa2042Pe.jsx b/frontend/src/components/Cerfa2042Pe.jsx new file mode 100644 index 0000000..0128394 --- /dev/null +++ b/frontend/src/components/Cerfa2042Pe.jsx @@ -0,0 +1,210 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api.js'; + +/* ── CERFA 2042 (PE, détention directe) ────────────────────────────────── + * Pendant du composant Cerfa2042Preview.jsx (crowdlending), demande Olivier du + * 20/09/26 : "il faut que tu reprenne l'onglet CERFA 2042 [...] pour les + * plateforme en flat Tax (direct)". Reprend le même format visuel (pivot + * mensuel + report annuel par case), mais le contenu est adapté : + * - Pas de distinction FR/étranger (hors sujet pour le PE dans ce chantier). + * - Deux pivots mensuels séparés au lieu d'un seul : l'intérêt sur capital + * non déployé (case 2TR) et la plus-value réalisée (case 3VG) ne sont + * JAMAIS fusionnés, car ils empruntent des circuits déclaratifs différents + * (2TR est transmis par l'IFU/2561 ; 3VG/3VH passent par le 2074, cf. + * Cerfa2561Pe.jsx et le commentaire de taxreportPe.js). + * - Consomme le même endpoint que Cerfa2561Pe.jsx (/taxreport-pe/cerfa2561, + * déjà filtré côté backend sur mode_detention = 'direct'). + */ + +const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; +const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12']; +const r2 = v => Math.round((v ?? 0) * 100) / 100; +const fmtN = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const fmtI = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); }; + +/* ── Ligne de case 2042 (repris du format Case2042 de Cerfa2042Preview.jsx) ── */ +function Case2042({ code, label, note, value, breakdown, hors2561 }) { + if (!value) return null; + return ( +
+
+
{code}
+
+
+ {label} + {hors2561 && ( + + hors IFU — formulaire 2074 + + )} +
+ {note &&
{note}
} +
+
{fmtI(value)} €
+
+ {breakdown && breakdown.length > 0 && ( +
+ {breakdown.map((p, i) => ( +
+ └ {p.nom} + {fmtI(p.val)} € +
+ ))} +
+ )} +
+ ); +} + +/* ── Pivot mensuel générique (repris du format matriceView de Cerfa2042Preview.jsx) ── */ +function PivotTable({ title, subtitle, lignes, getMonthVal, footerBg, footerColor }) { + const totalByMonth = m => r2(lignes.reduce((s, l) => s + getMonthVal(l, m), 0)); + const rowTotal = l => r2(MOIS_NUMS.reduce((s, m) => s + getMonthVal(l, m), 0)); + const grandTotal = r2(MOIS_NUMS.reduce((s, m) => s + totalByMonth(m), 0)); + return ( +
+
+

{title}

+ {subtitle} +
+ + + + + {MOIS_LABELS.map(m => ( + + ))} + + + + + {lignes.map(l => { + const total = rowTotal(l); + return ( + + + {MOIS_NUMS.map(m => { + const v = getMonthVal(l, m); + return ; + })} + + + ); + })} + + + + + {MOIS_NUMS.map(m => { + const v = totalByMonth(m); + return ; + })} + + + +
Plateforme — Détenteur{m}Total
+ {l.plateforme_nom} + — {fullName(l)} + 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}{total > 0 ? fmtN(total) + ' €' : '—'}
Total 0 ? footerColor : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}{fmtN(grandTotal)} €
+
+ ); +} + +export default function Cerfa2042Pe({ annee, activeView }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [view, setView] = useState('matrice'); + + useEffect(() => { + setLoading(true); + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + api.get('/taxreport-pe/cerfa2561', { annee, ...scopeParams }) + .then(setData) + .finally(() => setLoading(false)); + }, [annee, activeView]); + + const lignes = data?.lignes ?? []; + + const matriceView = ( +
+ {loading &&
Chargement…
} + {!loading && lignes.length === 0 && ( +

Aucune donnée pour {annee} (deals en détention directe).

+ )} + {!loading && lignes.length > 0 && ( + <> + l.mois?.[m]?.interets_bruts ?? 0} + footerBg="rgba(99,102,241,0.06)" + footerColor="var(--primary)" + /> + l.mois?.[m]?.plus_values ?? 0} + footerBg="rgba(16,185,129,0.06)" + footerColor="var(--success)" + /> + + )} +
+ ); + + const donneesView = (() => { + if (loading) return
Chargement…
; + + const total2TR = lignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0); + const total3VG = lignes.reduce((s, l) => s + (l.case_3VG ?? 0), 0); + const total3VH = lignes.reduce((s, l) => s + (l.case_3VH ?? 0), 0); + const bd2TR = lignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR })); + const bd3VG = lignes.filter(l => l.case_3VG > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_3VG })); + const bd3VH = lignes.filter(l => l.case_3VH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_3VH })); + + return ( +
+
+

Report annuel — Déclaration 2042

+ Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1} — deals en détention directe +
+
+ + + +
+
+ Synthèse fiscale + La case 2TR est normalement pré-remplie par l'administration à partir de l'IFU transmis par vos plateformes. + Les cases 3VG et 3VH (formulaire 2074) ne sont jamais transmises automatiquement — reportez-les + vous-même après compensation entre les plus et moins-values de cession de l'année. +
+
+ ); + })(); + + return ( +
+
+ +
+ {view === 'matrice' && matriceView} + {view === 'donnees' && donneesView} +
+
+
+ ); +} diff --git a/frontend/src/components/Cerfa2561Pe.jsx b/frontend/src/components/Cerfa2561Pe.jsx new file mode 100644 index 0000000..c4c81af --- /dev/null +++ b/frontend/src/components/Cerfa2561Pe.jsx @@ -0,0 +1,333 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../api.js'; + +/* ── CERFA 2561 / IFU (PE, détention directe) ──────────────────────────── + * Pendant du composant Cerfa2561Preview.jsx (crowdlending), demande Olivier + * du 20/09/26 : "il faut que tu reprenne l'onglet CERFA 2042 et 2551 [2561] + * pour les plateforme en flat Tax (direct)". Reprend le même format visuel + * (formulaire papier simulé — Cell/SectionTitle/FieldRow), mais le contenu + * est adapté à la fiscalité réelle d'un deal PE : + * - Case AR → 2TR (produits de placement à revenu fixe) uniquement — jamais + * KR → 2TT (produits des prêts participatifs, régime spécifique aux prêts + * de financement participatif, art. 125-00 A du CGI, qui ne s'applique + * PAS à une part de fonds PE). Cf. commentaire détaillé dans + * taxreportPe.js sur la correction 2TY → 3VH de ce chantier. + * - Les plus-values réalisées (case 3VG) et les moins-values de cession / + * pertes définitives (case 3VH) sont présentées à part, clairement + * signalées comme "hors IFU" : aucune case du formulaire 2561 ne couvre + * les cessions de valeurs mobilières — elles se déclarent via le + * formulaire 2074, qui reporte ensuite 3VG/3VH sur la 2042. + * - Pas de distinction FR/étranger ni de "type_produit_fiscal" (tous les + * deals PE en détention directe sont traités en flat tax dans ce + * chantier — cf. taxreportPe.js). + */ + +const fmtEUR = n => { if (!n && n !== 0) return ''; return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n); }; +const fmtEURDec = n => { if (!n && n !== 0) return ''; return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); }; +const r2 = v => Math.round((v ?? 0) * 100) / 100; +const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); }; + +/* ── Cellule du formulaire (repris du format Cell de Cerfa2561Preview.jsx) ── */ +/* hors2561 remplace noReport : contrairement au 2TY/AS crowdlending (jamais reporté nulle + * part), les cases 3VG/3VH d'un deal PE SONT reportées sur la 2042 — mais via le 2074, + * jamais via cet IFU. Le libellé doit donc dire "reporté via 2074", pas "sans report". */ +function Cell({ label, code2561, code2042, value, filled, hors2561 }) { + return ( +
+
{code2561}
+
+
{label}
+
+ {hors2561 ? 'Reporté sur la 2042 via le formulaire 2074 — jamais via cet IFU' : 'Montant à reporter sur votre déclaration 2042'} +
+
+
{code2042 ?? '—'}
+
+ {filled ? fmtEUR(value) + ' €' : '—'} +
+
+ ); +} + +function SectionTitle({ children }) { + return ( +
+ {children} +
+ ); +} + +function FieldRow({ label, code, value }) { + return ( +
+
{code}
+
{label}
+
{value || ''}
+
+ ); +} + +/* ── Cellule éditable (repris tel quel de Cerfa2561Preview.jsx — même /api/plateforme-tax, + * route générique non spécifique au crowdlending, réutilisable ici sans modification). ── */ +function EditableFieldRow({ label, code, value, onSave }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value ?? ''); + const inputRef = useRef(null); + + const startEdit = () => { setDraft(value ?? ''); setEditing(true); }; + const confirm = () => { onSave(draft.trim() || null); setEditing(false); }; + const onKey = e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); }; + + useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]); + + return ( +
+
{code}
+
{label}
+
+ {editing ? ( + <> + setDraft(e.target.value)} + onKeyDown={onKey} + style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 11, padding: '3px 8px', color: '#111' }} + placeholder="Saisir…" + /> + + + ) : ( + + {value || 'Cliquer pour saisir…'} + + )} +
+
+ ); +} + +/* ── Formulaire d'une ligne (plateforme × investisseur, détention directe) ── */ +function Cerfa2561FormPe({ ligne, index, total }) { + const [taxDetails, setTaxDetails] = useState(null); + + useEffect(() => { + api.get(`/plateforme-tax/${ligne.plateforme_id}/${ligne.annee}`) + .then(setTaxDetails) + .catch(() => setTaxDetails({ raison_sociale: ligne.plateforme_nom, siret_n: null, siret_n1: null })); + }, [ligne.plateforme_id, ligne.annee]); + + const save = field => val => { + if (!taxDetails) return; + const updated = { ...taxDetails, [field]: val }; + setTaxDetails(updated); + api.patch(`/plateforme-tax/${taxDetails.id}`, { [field]: val }); + }; + + const interetsNets = r2(ligne.interets_bruts - ligne.prelev_sociaux - ligne.prelev_forfaitaire); + const hasCession = ligne.case_3VG > 0 || ligne.case_3VH > 0; + + return ( +
+
+
+
CERFA N°2561 — N°11428*27
+
Déclaration récapitulative des opérations sur valeurs mobilières et revenus de capitaux mobiliers
+
+
+
{ligne.annee}
+
Simulation — non officielle
+
+
+ +
+
+ ⚠ Ce document est une simulation générée par votre outil de suivi, pour vos deals Private Equity en détention directe. + Il ne remplace pas le formulaire officiel émis par {ligne.plateforme_nom}. Il ne couvre que l'intérêt sur capital non + déployé — les plus ou moins-values de cession ne figurent jamais sur ce formulaire (cf. bandeau ci-dessous). +
+ +
+
+ Désignation du payeur (plateforme) + + + + + Désignation du bénéficiaire (investisseur) + + +
+ +
+ Informations générales + + + + Récapitulatif fiscal — intérêt non déployé +
+
+ Intérêts bruts{fmtEURDec(ligne.interets_bruts)} € +
+
+ − Prélèvements sociaux−{fmtEURDec(ligne.prelev_sociaux)} € +
+
+ − PFNL (12,8%)−{fmtEURDec(ligne.prelev_forfaitaire)} € +
+
+ Intérêts nets{fmtEURDec(interetsNets)} € +
+
+ ✓ Détention directe — PS et PFU déjà prélevés à la source par la plateforme +
+
+
+
+ + Cases à remplir sur le formulaire 2561 + +
+ PRODUITS DE PLACEMENT À REVENU FIXE +
+ 0} + /> + + {ligne.case_2BH > 0 && <> +
+ PRODUITS POUR LESQUELS LES PRÉLÈVEMENTS SOCIAUX ONT DÉJÀ ÉTÉ APPLIQUÉS +
+ 0} + /> + } + + {ligne.case_2CK > 0 && <> +
+ CRÉDIT D'IMPÔT PRÉLÈVEMENT +
+ 0} + /> + } + + {hasCession && ( + <> +
+ HORS FORMULAIRE 2561 — PLUS OU MOINS-VALUES DE CESSION (FORMULAIRE 2074) +
+
+ Aucune case du formulaire 2561 ne couvre les cessions de parts de fonds : la plus-value réalisée et la moins-value + définitive constatées cette année ne figurent jamais sur l'IFU. Déclarez-les vous-même via le formulaire 2074, qui + reporte ensuite le résultat net sur les cases 3VG / 3VH de votre 2042. +
+ {ligne.case_3VG > 0 && ( + + )} + {ligne.case_3VH > 0 && ( + + )} + + )} +
+
+ ); +} + +export default function Cerfa2561Pe({ annee, activeView }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [filterKey, setFilterKey] = useState('all'); + const contentRef = useRef(null); + + useEffect(() => { + setLoading(true); + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + api.get('/taxreport-pe/cerfa2561', { annee, ...scopeParams }) + .then(d => { + setData(d); + if (d?.lignes?.[0]) setFilterKey(`${d.lignes[0].plateforme_id}_${d.lignes[0].investisseur_id}`); + }) + .finally(() => setLoading(false)); + }, [annee, activeView]); + + const lignes = data?.lignes ?? []; + + const handlePrint = () => { + const content = contentRef.current; + if (!content) return; + const platName = lignes.find(l => `${l.plateforme_id}_${l.investisseur_id}` === filterKey)?.plateforme_nom ?? 'Plateforme'; + const printWin = window.open('', '_blank', 'width=900,height=700'); + printWin.document.write(` + + CERFA 2561 — ${annee} — ${platName} + + ${content.innerHTML}`); + printWin.document.close(); + printWin.focus(); + setTimeout(() => { printWin.print(); printWin.close(); }, 400); + }; + + return ( +
+
+ {lignes.length > 0 && ( + + )} +
+ +
+ + {loading &&
Chargement…
} + {!loading && lignes.length === 0 && ( +

Aucun deal en détention directe avec des distributions pour {annee}.

+ )} + +
+ {!loading && lignes + .filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterKey) + .map((ligne, i, arr) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 4432633..2dc6531 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -368,11 +368,17 @@ export default function Layout() { Frais )} + {/* Fiscalité (20/09/26, chantier "Fiscalité PE") a désormais son équivalent PE + (TaxReportPe.jsx, TaxReportRouter dans App.jsx) — sortie du bloc isCrowdlendingActive + pour devenir visible quel que soit le workspace actif, même principe que + Plateformes/Investissements/Dépôts-Retraits/Remboursements ci-dessus. Assistant IA + reste, lui, réservé au crowdlending (aucun équivalent PE, cf. RequireWorkspace sur + sa route dans App.jsx). */} + + Fiscalité + {isCrowdlendingActive && ( <> - - Fiscalité - {aiEnabled && ( Assistant IA diff --git a/frontend/src/components/RembFormModal.jsx b/frontend/src/components/RembFormModal.jsx index a6d6b28..6709d80 100644 --- a/frontend/src/components/RembFormModal.jsx +++ b/frontend/src/components/RembFormModal.jsx @@ -214,6 +214,10 @@ export default function RembFormModal({ + {/* Plus-value réalisée (20/09/26, chantier "Fiscalité PE") — cf. distribution_nature + dans db/index.js : le fonds distribue le produit de ses propres cessions au fil de + l'eau plutôt qu'en une seule vente, donc chaque distribution porte sa propre nature. */} +
)} diff --git a/frontend/src/pages/InvestissementPeDetail.jsx b/frontend/src/pages/InvestissementPeDetail.jsx index 2e12f20..520a678 100644 --- a/frontend/src/pages/InvestissementPeDetail.jsx +++ b/frontend/src/pages/InvestissementPeDetail.jsx @@ -9,7 +9,7 @@ import InvestissementPeFormModal from '../components/InvestissementPeFormModal.j import FraisFormModal from '../components/FraisFormModal.jsx'; import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx'; import { useAsyncAction } from '../hooks/useAsyncAction.js'; -import { fmtEUR, fmtDate, fmtOctets, today } from '../utils/format.js'; +import { fmtEUR, fmtDate, fmtOctets, today, MODE_DETENTION_LABELS } from '../utils/format.js'; import { emptyDealForm, AUTO_VAL_NOTE, AUTO_VAL_NOTE_LEGACY } from './InvestissementsPe.jsx'; /** @@ -44,12 +44,6 @@ const STATUT_LABELS = { perte_definitive: 'Perte définitive', }; -const MODE_DETENTION_LABELS = { - direct: 'Direct', - pea_pme: 'PEA / PEA-PME', - assurance_vie: 'Assurance-vie', -}; - const FRAIS_MODE_LABELS = { source: 'À la source', portefeuille: 'Porte-monnaie', @@ -356,6 +350,63 @@ export default function InvestissementPeDetail() { } catch (e) { setErr(e.message); } }; + /* ── Déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ────────── + * Même principe que InvestissementDetail.jsx côté crowdlending (openPerteModal/submitPerte/ + * cancelPerteDeclaration) mais adapté : un deal PE n'a pas de statut intermédiaire de + * recouvrement (en_retard/procedure), donc l'action est proposée pour tout statut autre que + * 'perte_definitive' plutôt que réservée à un sous-ensemble (cf. POST /:id/perte-definitive + * côté backend, même garde-fou). Montant préfilli avec le capital pas encore restitué + * (montant investi/investissable − distributions déjà classées "capital"), mais reste éditable + * (perte partielle possible si une partie a déjà été récupérée). + */ + const [perteModalOpen, setPerteModalOpen] = useState(false); + const [perteForm, setPerteForm] = useState({ date_effet: today(), montant_perte: '', motif: '' }); + const [perteErr, setPerteErr] = useState(null); + const [perteSaving, setPerteSaving] = useState(false); + const [confirmingPerteCancel, setConfirmingPerteCancel] = useState(false); + + const openPerteModal = () => { + const capInvestissable = deal?.capital_investissable ?? deal?.montant_investi ?? 0; + const capNonRestitue = Math.max(0, capInvestissable - (deal?.capital_restitue_cumule || 0)); + setPerteForm({ date_effet: today(), montant_perte: String(Math.round(capNonRestitue * 100) / 100), motif: '' }); + setPerteErr(null); + setPerteModalOpen(true); + }; + const closePerteModal = () => { setPerteModalOpen(false); setPerteErr(null); }; + + const submitPerte = async (e) => { + e?.preventDefault?.(); + setPerteErr(null); + if (!perteForm.date_effet) { setPerteErr("La date d'effet est requise."); return; } + if (perteForm.montant_perte === '' || Number(perteForm.montant_perte) < 0) { + setPerteErr('Le montant de la perte doit être un nombre positif ou nul.'); + return; + } + if (!perteForm.motif.trim()) { setPerteErr('Le motif est obligatoire.'); return; } + setPerteSaving(true); + try { + await api.post(`/investissements-pe/${id}/perte-definitive`, { + date_effet: perteForm.date_effet, + montant_perte: Number(perteForm.montant_perte), + motif: perteForm.motif.trim(), + }); + setPerteModalOpen(false); + await load(); + } catch (e) { + const fieldErrors = e.details?.fieldErrors; + const detail = fieldErrors ? Object.values(fieldErrors).flat().filter(Boolean).join(' ') : ''; + setPerteErr(detail || e.message); + } finally { setPerteSaving(false); } + }; + + const cancelPerteDeclaration = async () => { + try { + await api.del(`/investissements-pe/${id}/perte-definitive`); + setConfirmingPerteCancel(false); + await load(); + } catch (e) { setConfirmingPerteCancel(false); } + }; + /* ── Valorisations ── */ const [valForm, setValForm] = useState(emptyValForm); const [valErr, setValErr] = useState(null); @@ -1010,6 +1061,72 @@ export default function InvestissementPeDetail() { onCancel={() => setDocDeleteTarget(null)} /> + {/* ── Modal déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ── + Même contenu que la modale équivalente côté crowdlending (InvestissementDetail.jsx), + adapté au vocabulaire PE ("deal" plutôt que "dossier", case 2TY via taxreport-pe). */} + + + + + } + > +

+ À utiliser uniquement lorsque le capital est réellement irrécouvrable (fonds liquidé + sans distribution, société sous-jacente radiée…) — pas une simple baisse de + valorisation latente. Une fois déclaré, ce deal sort du capital investi/en risque et + le montant est reporté dans votre synthèse fiscale PE (case 2TY), pour l'année de la + date d'effet ci-dessous. +

+
+ + setPerteForm(f => ({ ...f, date_effet: e.target.value }))} + required + /> + + setPerteForm(f => ({ ...f, montant_perte: e.target.value }))} + /> +
+ Préfilli avec le capital pas encore restitué — ajustez si une partie a déjà été + récupérée (perte partielle). +
+ +