MAJ
This commit is contained in:
1 parent
14cf4e5a19
commit
26197fc411
15 files changed
+1775
-28
No files matched your search
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
+12
-1
@@ -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 <Plateformes />;
|
||||
}
|
||||
|
||||
// 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 <TaxReportPe />;
|
||||
return <TaxReport />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
|
||||
@@ -215,7 +226,7 @@ export default function App() {
|
||||
<Route path="remboursements" element={<RemboursementsRouter />} />
|
||||
<Route path="frais" element={<FraisRouter />} />
|
||||
<Route path="simul" element={<RequireWorkspace type="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<RequireWorkspace type="crowdlending"><TaxReport /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<TaxReportRouter />} />
|
||||
<Route path="assistant-ia" element={<RequireWorkspace type="crowdlending"><AssistantIA /></RequireWorkspace>} />
|
||||
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
|
||||
<Route path="imports" element={<Navigate to="/settings?section=imports" replace />} />
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{label}
|
||||
{hors2561 && (
|
||||
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: 'rgba(245,158,11,0.12)', color: '#b45309', fontWeight: 600, marginLeft: 8, whiteSpace: 'nowrap' }}>
|
||||
hors IFU — formulaire 2074
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmtI(value)} €</div>
|
||||
</div>
|
||||
{breakdown && breakdown.length > 0 && (
|
||||
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{breakdown.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
<span>└ {p.nom}</span>
|
||||
<span style={{ fontWeight: 500, marginLeft: 12 }}>{fmtI(p.val)} €</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>{title}</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>{subtitle}</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme — Détenteur</th>
|
||||
{MOIS_LABELS.map(m => (
|
||||
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
|
||||
))}
|
||||
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map(l => {
|
||||
const total = rowTotal(l);
|
||||
return (
|
||||
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{l.plateforme_nom}
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>— {fullName(l)}</span>
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = getMonthVal(l, m);
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', background: footerBg }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700, color: footerColor }}>Total</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = totalByMonth(m);
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? footerColor : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: footerColor }}>{fmtN(grandTotal)} €</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
{loading && <div className="card text-muted">Chargement…</div>}
|
||||
{!loading && lignes.length === 0 && (
|
||||
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune donnée pour {annee} (deals en détention directe).</p></div>
|
||||
)}
|
||||
{!loading && lignes.length > 0 && (
|
||||
<>
|
||||
<PivotTable
|
||||
title="Suivi mensuel — Intérêt sur capital non déployé"
|
||||
subtitle={`Revenus ${annee} — deals en détention directe (case 2TR)`}
|
||||
lignes={lignes}
|
||||
getMonthVal={(l, m) => l.mois?.[m]?.interets_bruts ?? 0}
|
||||
footerBg="rgba(99,102,241,0.06)"
|
||||
footerColor="var(--primary)"
|
||||
/>
|
||||
<PivotTable
|
||||
title="Suivi mensuel — Plus-values réalisées"
|
||||
subtitle={`Cessions ${annee} — deals en détention directe (case 3VG, hors IFU — formulaire 2074)`}
|
||||
lignes={lignes}
|
||||
getMonthVal={(l, m) => l.mois?.[m]?.plus_values ?? 0}
|
||||
footerBg="rgba(16,185,129,0.06)"
|
||||
footerColor="var(--success)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const donneesView = (() => {
|
||||
if (loading) return <div className="card text-muted">Chargement…</div>;
|
||||
|
||||
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 (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1} — deals en détention directe</span>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
||||
<Case2042 code="2TR" label="Intérêt sur capital non déployé" note="Revenus de capitaux mobiliers — transmis par vos plateformes via l'IFU (onglet CERFA 2561)" value={total2TR || undefined} breakdown={bd2TR} />
|
||||
<Case2042 code="3VG" label="Plus-value réalisée" note="Case du formulaire 2074 — jamais transmise via l'IFU, à reporter vous-même" value={total3VG || undefined} breakdown={bd3VG} hors2561 />
|
||||
<Case2042 code="3VH" label="Moins-value de cession (perte définitive)" note="Case du formulaire 2074 — imputable uniquement sur des plus-values mobilières de l'année ou des dix années suivantes" value={total3VH || undefined} breakdown={bd3VH} hors2561 />
|
||||
</div>
|
||||
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
|
||||
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Synthèse fiscale</strong>
|
||||
La case <strong>2TR</strong> est normalement pré-remplie par l'administration à partir de l'IFU transmis par vos plateformes.
|
||||
Les cases <strong>3VG</strong> et <strong>3VH</strong> (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.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
|
||||
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
|
||||
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>Sections</div>
|
||||
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||
Suivi mensuel
|
||||
</button>
|
||||
<button className={`account-nav-item${view === 'donnees' ? ' active' : ''}`} onClick={() => setView('donnees')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
Données 2042
|
||||
</button>
|
||||
</nav>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{view === 'matrice' && matriceView}
|
||||
{view === 'donnees' && donneesView}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1, background: filled ? '#fffbe6' : '#fff' }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRight: '1px solid #aaa', background: '#f5f3ff', fontSize: 11, fontWeight: 700, color: '#7c3aed' }}>{code2561}</div>
|
||||
<div style={{ flex: 1, padding: '5px 8px', fontSize: 11, color: '#222', lineHeight: 1.4, borderRight: '1px solid #aaa' }}>
|
||||
<div>{label}</div>
|
||||
<div style={{ fontSize: 10, color: hors2561 ? '#9a3412' : '#555', marginTop: 2 }}>
|
||||
{hors2561 ? 'Reporté sur la 2042 via le formulaire 2074 — jamais via cet IFU' : 'Montant à reporter sur votre déclaration 2042'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 48, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRight: '1px solid #aaa', background: filled ? '#111' : '#555', fontSize: 11, fontWeight: 700, color: '#fff' }}>{code2042 ?? '—'}</div>
|
||||
<div style={{ width: 90, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', padding: '0 10px', fontSize: 12, fontWeight: filled ? 700 : 400, color: filled ? '#14532d' : '#bbb', background: filled ? '#f0fdf4' : '#fafafa' }}>
|
||||
{filled ? fmtEUR(value) + ' €' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div style={{ background: '#5b21b6', color: '#fff', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', fontWeight: 700, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.04em', padding: '4px 8px', marginTop: 10, marginBottom: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ label, code, value }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
|
||||
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '3px 8px', fontSize: 11, color: value ? '#111' : 'transparent', background: '#fff' }}>{value || ''}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
|
||||
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
|
||||
<div
|
||||
onClick={!editing ? startEdit : undefined}
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', background: editing ? '#f5f3ff' : '#fff', cursor: editing ? 'default' : 'text', position: 'relative' }}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 11, padding: '3px 8px', color: '#111' }}
|
||||
placeholder="Saisir…"
|
||||
/>
|
||||
<button onClick={confirm} title="Valider" style={{ flexShrink: 0, border: 'none', background: 'none', cursor: 'pointer', padding: '0 8px', color: '#7c3aed', fontSize: 14, fontWeight: 700 }}>✓</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ padding: '3px 8px', fontSize: 11, color: value ? '#111' : '#bbb', fontStyle: value ? 'normal' : 'italic' }}>
|
||||
{value || 'Cliquer pour saisir…'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<div style={{
|
||||
fontFamily: 'Arial, sans-serif', maxWidth: 900, margin: '0 auto 40px',
|
||||
border: '2px solid #5b21b6', pageBreakAfter: index < total - 1 ? 'always' : 'auto',
|
||||
colorScheme: 'light', background: '#fff', color: '#111',
|
||||
}}>
|
||||
<div style={{ background: '#5b21b6', color: '#fff', padding: '10px 14px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.05em' }}>CERFA N°2561 — N°11428*27</div>
|
||||
<div style={{ fontSize: 11, marginTop: 2, opacity: .85 }}>Déclaration récapitulative des opérations sur valeurs mobilières et revenus de capitaux mobiliers</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>{ligne.annee}</div>
|
||||
<div style={{ fontSize: 10, opacity: .8 }}>Simulation — non officielle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 10 }}>
|
||||
<div style={{ background: '#fff8e1', border: '1px solid #f59e0b', borderRadius: 4, padding: '6px 10px', fontSize: 10, color: '#92400e', marginBottom: 10 }}>
|
||||
⚠ 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).
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<SectionTitle>Désignation du payeur (plateforme)</SectionTitle>
|
||||
<EditableFieldRow label="Raison sociale" code="ZM"
|
||||
value={taxDetails?.raison_sociale ?? ligne.plateforme_nom}
|
||||
onSave={save('raison_sociale')} />
|
||||
<EditableFieldRow label={`N° SIRET au 31-12-${Number(ligne.annee) - 1}`} code="ZT"
|
||||
value={taxDetails?.siret_n1 ?? null}
|
||||
onSave={save('siret_n1')} />
|
||||
<EditableFieldRow label={`N° SIRET au 31-12-${ligne.annee}`} code="ZS"
|
||||
value={taxDetails?.siret_n ?? null}
|
||||
onSave={save('siret_n')} />
|
||||
|
||||
<SectionTitle>Désignation du bénéficiaire (investisseur)</SectionTitle>
|
||||
<FieldRow label="Nom de famille" code="ZC" value={ligne.investisseur_nom} />
|
||||
<FieldRow label="Prénoms" code="ZD" value={ligne.investisseur_prenom} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle>Informations générales</SectionTitle>
|
||||
<FieldRow label="Période de référence" code="AQ" value="0101 – 1231" />
|
||||
<FieldRow label="Année fiscale" code="—" value={ligne.annee} />
|
||||
|
||||
<SectionTitle>Récapitulatif fiscal — intérêt non déployé</SectionTitle>
|
||||
<div style={{ border: '1px solid #aaa', padding: '6px 8px', background: '#f8faff', fontSize: 11 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<span>Intérêts bruts</span><strong>{fmtEURDec(ligne.interets_bruts)} €</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
|
||||
<span>− Prélèvements sociaux</span><span>−{fmtEURDec(ligne.prelev_sociaux)} €</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
|
||||
<span>− PFNL (12,8%)</span><span>−{fmtEURDec(ligne.prelev_forfaitaire)} €</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #ccc', paddingTop: 3, fontWeight: 700 }}>
|
||||
<span>Intérêts nets</span><span>{fmtEURDec(interetsNets)} €</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, color: '#666' }}>
|
||||
✓ Détention directe — PS et PFU déjà prélevés à la source par la plateforme
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionTitle>Cases à remplir sur le formulaire 2561</SectionTitle>
|
||||
|
||||
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
|
||||
PRODUITS DE PLACEMENT À REVENU FIXE
|
||||
</div>
|
||||
<Cell
|
||||
label="Gains — produits de placement à revenu fixe"
|
||||
code2561="AR" code2042="2TR" value={ligne.case_2TR} filled={ligne.case_2TR > 0}
|
||||
/>
|
||||
|
||||
{ligne.case_2BH > 0 && <>
|
||||
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#5b21b6', borderBottom: '1px solid #aaa', marginTop: 6 }}>
|
||||
PRODUITS POUR LESQUELS LES PRÉLÈVEMENTS SOCIAUX ONT DÉJÀ ÉTÉ APPLIQUÉS
|
||||
</div>
|
||||
<Cell
|
||||
label="Produits susceptibles d'ouvrir droit à CSG déductible en cas d'option pour le barème progressif"
|
||||
code2561="DQ" code2042="2BH" value={ligne.case_2BH} filled={ligne.case_2BH > 0}
|
||||
/>
|
||||
</>}
|
||||
|
||||
{ligne.case_2CK > 0 && <>
|
||||
<div style={{ background: '#fef9c3', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#854d0e', borderBottom: '1px solid #aaa', marginTop: 6 }}>
|
||||
CRÉDIT D'IMPÔT PRÉLÈVEMENT
|
||||
</div>
|
||||
<Cell
|
||||
label="Crédit d'impôt prélèvement — PFNL déjà versé (12,8%)"
|
||||
code2561="AD" code2042="2CK" value={ligne.case_2CK} filled={ligne.case_2CK > 0}
|
||||
/>
|
||||
</>}
|
||||
|
||||
{hasCession && (
|
||||
<>
|
||||
<div style={{ background: '#fef3c7', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#92400e', borderBottom: '1px solid #aaa', marginTop: 10 }}>
|
||||
HORS FORMULAIRE 2561 — PLUS OU MOINS-VALUES DE CESSION (FORMULAIRE 2074)
|
||||
</div>
|
||||
<div style={{ border: '1px solid #aaa', borderTop: 'none', padding: '8px 10px', fontSize: 11, color: '#78350f', background: '#fffbeb' }}>
|
||||
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.
|
||||
</div>
|
||||
{ligne.case_3VG > 0 && (
|
||||
<Cell label="Plus-value réalisée sur cession" code2561="—" code2042="3VG" value={ligne.case_3VG} filled hors2561 />
|
||||
)}
|
||||
{ligne.case_3VH > 0 && (
|
||||
<Cell label="Moins-value de cession (perte définitive)" code2561="—" code2042="3VH" value={ligne.case_3VH} filled hors2561 />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(`<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>CERFA 2561 — ${annee} — ${platName}</title>
|
||||
<style>
|
||||
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
||||
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; background: white; color: #111; }
|
||||
@media print { body { padding: 0; } }
|
||||
.no-print { display: none !important; }
|
||||
</style>
|
||||
</head><body>${content.innerHTML}</body></html>`);
|
||||
printWin.document.close();
|
||||
printWin.focus();
|
||||
setTimeout(() => { printWin.print(); printWin.close(); }, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="no-print" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
{lignes.length > 0 && (
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={e => setFilterKey(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-sm)', padding: '4px 8px', width: 240 }}
|
||||
>
|
||||
{lignes.map(l => (
|
||||
<option key={`${l.plateforme_id}_${l.investisseur_id}`} value={`${l.plateforme_id}_${l.investisseur_id}`}>
|
||||
{l.plateforme_nom} — {fullName(l)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="icon-btn" onClick={handlePrint} disabled={loading || !lignes.length} title="Imprimer / Enregistrer en PDF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 6 2 18 2 18 9"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
|
||||
<rect x="6" y="14" width="12" height="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ textAlign: 'center', color: 'var(--text-muted)', paddingTop: 20 }}>Chargement…</div>}
|
||||
{!loading && lignes.length === 0 && (
|
||||
<p className="text-muted" style={{ margin: 0 }}>Aucun deal en détention directe avec des distributions pour {annee}.</p>
|
||||
)}
|
||||
|
||||
<div ref={contentRef}>
|
||||
{!loading && lignes
|
||||
.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterKey)
|
||||
.map((ligne, i, arr) => (
|
||||
<Cerfa2561FormPe key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={{ ...ligne, annee }} index={i} total={arr.length} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -368,11 +368,17 @@ export default function Layout() {
|
||||
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{isCrowdlendingActive && (
|
||||
<>
|
||||
{/* 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). */}
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
{isCrowdlendingActive && (
|
||||
<>
|
||||
{aiEnabled && (
|
||||
<NavLink to="/assistant-ia" title="Assistant IA">
|
||||
<NavIcon libFilename={navIcons.assistantIa} Fallback={IconAssistantIa} /><span className="nav-label">Assistant IA</span>
|
||||
|
||||
@@ -214,6 +214,10 @@ export default function RembFormModal({
|
||||
<option value="">— Non précisé —</option>
|
||||
<option value="capital">Restitution de capital déployé</option>
|
||||
<option value="interet_non_deploye">Intérêt sur capital non déployé</option>
|
||||
{/* 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. */}
|
||||
<option value="plus_value">Plus-value réalisée</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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). */}
|
||||
<Modal
|
||||
open={perteModalOpen}
|
||||
title="Déclarer une perte définitive"
|
||||
onClose={closePerteModal}
|
||||
width={520}
|
||||
busy={perteSaving}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" disabled={perteSaving} onClick={closePerteModal}>Annuler</button>
|
||||
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
|
||||
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
|
||||
À 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.
|
||||
</p>
|
||||
<form onSubmit={submitPerte}>
|
||||
<label>Date d'effet (constat de la perte)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={perteForm.date_effet}
|
||||
onChange={e => setPerteForm(f => ({ ...f, date_effet: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<label style={{ marginTop: 12, display: 'block' }}>
|
||||
Montant de la perte
|
||||
<span className="text-muted"> — capital pas encore restitué : {fmtEUR(Math.max(0, (deal?.capital_investissable ?? deal?.montant_investi ?? 0) - (deal?.capital_restitue_cumule || 0)))}</span>
|
||||
</label>
|
||||
<input
|
||||
type="number" step="0.01" min="0" required
|
||||
value={perteForm.montant_perte}
|
||||
onChange={e => setPerteForm(f => ({ ...f, montant_perte: e.target.value }))}
|
||||
/>
|
||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
|
||||
Préfilli avec le capital pas encore restitué — ajustez si une partie a déjà été
|
||||
récupérée (perte partielle).
|
||||
</div>
|
||||
<label style={{ marginTop: 12, display: 'block' }}>Motif</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ex : liquidation du fonds, société sous-jacente radiée…"
|
||||
value={perteForm.motif}
|
||||
onChange={e => setPerteForm(f => ({ ...f, motif: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
{perteErr && <div className="form-error" style={{ marginTop: 10 }}>{perteErr}</div>}
|
||||
</form>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
open={confirmingPerteCancel}
|
||||
title="Annuler la déclaration de perte ?"
|
||||
message="Le deal retrouve son statut précédent et le montant sort de votre synthèse fiscale PE."
|
||||
confirmLabel="Annuler la déclaration"
|
||||
onConfirm={cancelPerteDeclaration}
|
||||
onCancel={() => setConfirmingPerteCancel(false)}
|
||||
/>
|
||||
|
||||
<FraisFormModal
|
||||
open={fraisModalOpen}
|
||||
onClose={closeFraisModal}
|
||||
@@ -1041,6 +1158,21 @@ export default function InvestissementPeDetail() {
|
||||
items={[
|
||||
{ label: 'Modifier', icon: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>, action: () => { setCardMenu(null); openEditDeal(); } },
|
||||
{ label: 'Exporter', icon: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>, action: () => { setCardMenu(null); exportDeal(); } },
|
||||
// Déclarer/annuler une perte définitive (20/09/26, chantier "Fiscalité PE") — même
|
||||
// paire d'actions que le menu "⋮" crowdlending (InvestissementDetail.jsx).
|
||||
deal && deal.statut !== 'perte_definitive' && {
|
||||
label: 'Déclarer une perte définitive',
|
||||
icon: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>,
|
||||
danger: true,
|
||||
separatorBefore: true,
|
||||
action: () => { setCardMenu(null); openPerteModal(); },
|
||||
},
|
||||
deal && deal.statut === 'perte_definitive' && {
|
||||
label: 'Annuler la déclaration de perte',
|
||||
icon: <><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-9.36L1 10"/></>,
|
||||
separatorBefore: true,
|
||||
action: () => { setCardMenu(null); setConfirmingPerteCancel(true); },
|
||||
},
|
||||
{ label: 'Supprimer', danger: true, separatorBefore: true, icon: <><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></>, action: () => { setCardMenu(null); setDeleteConfirm(true); } },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, memberLabel } from '../utils/format.js';
|
||||
import { fmtEUR, fmtDate, memberLabel, DISTRIBUTION_NATURE_LABELS } from '../utils/format.js';
|
||||
import {
|
||||
sortMouvements, computeSoldeIndicatifMap, signedMontant, MOUV_TYPE_LABELS,
|
||||
ExportDropdown, mouvToCSV, mouvToXLS, mouvToJSON, timestampSuffix, dlBlob,
|
||||
@@ -480,7 +480,8 @@ export default function PlateformesPe() {
|
||||
<td>{fmtDate(r.date_remb)}</td>
|
||||
<td>{deals.find(d => d.id === r.investissement_pe_id)?.nom_deal || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.investisseur_nom || '—'}</td>}
|
||||
<td className="text-muted">{r.distribution_nature === 'interet_non_deploye' ? 'Intérêt sur capital non déployé' : 'Restitution de capital'}</td>
|
||||
{/* 20/09/26, chantier "Fiscalité PE" : ternaire remplacé par la constante partagée DISTRIBUTION_NATURE_LABELS (utils/format.js) pour inclure la nouvelle nature 'plus_value' sans dupliquer les libellés (cf. RemboursementsSimple.jsx). */}
|
||||
<td className="text-muted">{DISTRIBUTION_NATURE_LABELS[r.distribution_nature] || 'Restitution de capital'}</td>
|
||||
<td className="num">{fmtEUR(r.net_recu)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -13,7 +13,7 @@ import Pagination from '../components/Pagination.jsx';
|
||||
import InteretsChart from '../components/InteretsChart.jsx';
|
||||
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
import { fmtEUR, fmtDate, today, DISTRIBUTION_NATURE_LABELS } from '../utils/format.js';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
bonus_plateforme: 'Bonus plateforme',
|
||||
@@ -21,11 +21,9 @@ const TYPE_LABELS = {
|
||||
interets_plateforme: 'Distribution',
|
||||
};
|
||||
|
||||
// Nature de la distribution (18/09/26) — affichée en sous-texte sous le type dans le tableau.
|
||||
const DISTRIBUTION_NATURE_LABELS = {
|
||||
capital: 'Restitution de capital',
|
||||
interet_non_deploye: 'Intérêt sur capital non déployé',
|
||||
};
|
||||
// Nature de la distribution (18/09/26 ; libellés extraits en constante partagée le 20/09/26,
|
||||
// chantier "Fiscalité PE" — cf. utils/format.js, réutilisée aussi par PlateformesPe.jsx).
|
||||
// Affichée en sous-texte sous le type dans le tableau.
|
||||
|
||||
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
|
||||
@@ -395,13 +393,30 @@ export default function RemboursementsSimple() {
|
||||
if (key === 'taxe_locale') {
|
||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local || 0) - Number(value));
|
||||
}
|
||||
if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc'].includes(key)) {
|
||||
// Recalcul PFU/PS (20/09/26, chantier "Fiscalité PE" — cf. taxreportPe.js) : désormais
|
||||
// dépendant à la fois de la nature de la distribution ET du mode de détention du deal lié
|
||||
// (investissements_pe.mode_detention), pas seulement de l'année comme avant. 'distribution_nature'
|
||||
// et 'investissement_pe_id' redéclenchent donc aussi ce recalcul, contrairement à avant.
|
||||
// - capital (restitution de capital déployé) : jamais imposable, quel que soit le mode.
|
||||
// - direct : PFU complet (IR + PS), comme avant (comportement historique inchangé).
|
||||
// - pea_pme : prélèvements sociaux seuls (IR nativement exonéré dans le plan).
|
||||
// - assurance_vie : aucun taux automatique (dépend du contrat) — laissé à 0, saisie manuelle.
|
||||
if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc', 'distribution_nature', 'investissement_pe_id'].includes(key)) {
|
||||
const dateStr = key === 'date_remb' ? value : next.date_remb;
|
||||
const base = round2((Number(next.interets_bruts) || 0) - (Number(next.frais_ttc) || 0));
|
||||
const rates = getRatesForYearPe(dateStr, pfuRates);
|
||||
if (rates) {
|
||||
const dealSel = deals.find(d => String(d.id) === String(next.investissement_pe_id));
|
||||
const modeDetention = dealSel?.mode_detention || 'direct';
|
||||
|
||||
if (next.distribution_nature === 'capital') {
|
||||
next.prelev_sociaux = 0;
|
||||
next.prelev_forfaitaire = 0;
|
||||
} else if (modeDetention === 'assurance_vie') {
|
||||
next.prelev_sociaux = 0;
|
||||
next.prelev_forfaitaire = 0;
|
||||
} else if (rates) {
|
||||
next.prelev_sociaux = round2(base * rates.prelev_sociaux / 100);
|
||||
next.prelev_forfaitaire = round2(base * rates.impot_revenu / 100);
|
||||
next.prelev_forfaitaire = modeDetention === 'pea_pme' ? 0 : round2(base * rates.impot_revenu / 100);
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import { fmtEUR, fmtDate, MODE_DETENTION_LABELS } from '../utils/format.js';
|
||||
import Cerfa2042Pe from '../components/Cerfa2042Pe.jsx';
|
||||
import Cerfa2561Pe from '../components/Cerfa2561Pe.jsx';
|
||||
|
||||
/* ── YearSelector ─────────────────────────────────────────────
|
||||
* Copié de TaxReport.jsx (même comportement — pas d'extraction en composant partagé pour
|
||||
* l'instant afin de ne pas toucher au fichier crowdlending existant dans ce chantier ; la
|
||||
* page Plateformes a son propre AnneeSelector, légèrement différent car il autorise un choix
|
||||
* "Depuis le début" hors de propos ici, une année étant toujours requise pour la fiscalité). */
|
||||
function YearSelector({ annee, setAnnee, availableYears }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 160 }}>
|
||||
<div
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
height: '100%', boxSizing: 'border-box',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
boxShadow: open
|
||||
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
userSelect: 'none',
|
||||
transition: 'box-shadow .15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
|
||||
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||
}}>Année</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: '1.8rem', fontWeight: 700, lineHeight: 1.1, marginTop: 6 }}>
|
||||
{annee}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
|
||||
minWidth: 160, overflow: 'hidden',
|
||||
}}>
|
||||
{availableYears.map(yr => {
|
||||
const isActive = String(yr) === String(annee);
|
||||
return (
|
||||
<div
|
||||
key={yr}
|
||||
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
|
||||
style={{
|
||||
padding: '10px 16px', cursor: 'pointer',
|
||||
background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent',
|
||||
color: isActive ? 'var(--primary)' : 'var(--text)',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
}}
|
||||
>
|
||||
{yr}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ligne d'information CERFA (repris du format InfoLigneCerfa de TaxReport.jsx) ── */
|
||||
function InfoLigneCerfa({ code, titre, texte }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
flexShrink: 0, minWidth: 62, textAlign: 'center',
|
||||
fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-xs)',
|
||||
color: 'var(--primary)', background: 'rgba(99,102,241,0.07)',
|
||||
borderRadius: 6, padding: '4px 6px', letterSpacing: '.03em', whiteSpace: 'nowrap',
|
||||
}}>{code}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{titre}</div>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.5 }}>{texte}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Schéma : quel formulaire alimente quel formulaire (PE, détention directe) ──
|
||||
* Adapté de CerfaFlowDiagram (TaxReport.jsx) : contrairement au crowdlending, deux
|
||||
* formulaires alimentent la 2042 en parallèle et ne sont JAMAIS fusionnés — le 2561
|
||||
* (intérêt sur capital non déployé) et le 2074 (plus ou moins-value de cession). ── */
|
||||
function CerfaFlowDiagramPe() {
|
||||
const boxFill = 'var(--surface-2)';
|
||||
const box = (x, y, w, h, code, label, highlight) => (
|
||||
<g key={code}>
|
||||
<rect x={x} y={y} width={w} height={h} rx={10}
|
||||
fill={highlight ? 'rgba(99,102,241,0.08)' : boxFill}
|
||||
stroke={highlight ? 'var(--primary)' : 'var(--border)'} strokeWidth={highlight ? 1.8 : 1.3} />
|
||||
<text x={x + w / 2} y={y + h / 2 - 6} textAnchor="middle" fontFamily="monospace" fontWeight="800"
|
||||
fontSize="14" fill="var(--primary)">{code}</text>
|
||||
<text x={x + w / 2} y={y + h / 2 + 13} textAnchor="middle" fontSize="10" fill="var(--text-muted)">{label}</text>
|
||||
</g>
|
||||
);
|
||||
const arrow = (x1, y1, x2, y2, key) => (
|
||||
<line key={key} x1={x1} y1={y1} x2={x2} y2={y2}
|
||||
stroke="var(--text-muted)" strokeWidth={1.6} markerEnd="url(#cerfaFlowArrowPe)" />
|
||||
);
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 480 190" style={{ width: '100%', maxWidth: 460, height: 'auto', display: 'block' }}>
|
||||
<defs>
|
||||
<marker id="cerfaFlowArrowPe" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="var(--text-muted)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{arrow(180, 35, 268, 78, 'a1')}
|
||||
{arrow(180, 150, 268, 108, 'a2')}
|
||||
{box(20, 10, 160, 50, '2561', 'IFU (intérêt)')}
|
||||
{box(20, 125, 160, 50, '2074', 'Plus/moins-values')}
|
||||
{box(278, 65, 190, 60, '2042', 'Déclaration revenus', true)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Carte "régime de détention" ── */
|
||||
function RegimeCard({ mode, bucket }) {
|
||||
const hasData = bucket.plus_value_realisee > 0 || bucket.interet_non_deploye > 0
|
||||
|| bucket.capital_restitue > 0 || bucket.non_classe > 0 || bucket.pertes_definitives > 0;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ flex: '1 1 300px' }}>
|
||||
<h3 style={{ marginTop: 0 }}>{MODE_DETENTION_LABELS[mode] || mode}</h3>
|
||||
{!hasData ? (
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>Aucun flux pour cette année.</p>
|
||||
) : (
|
||||
<div className="kpi-grid">
|
||||
{bucket.plus_value_realisee > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Plus-value réalisée</div>
|
||||
<div className="value">{fmtEUR(bucket.plus_value_realisee)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.interet_non_deploye > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Intérêt sur capital non déployé</div>
|
||||
<div className="value">{fmtEUR(bucket.interet_non_deploye)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.capital_restitue > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Capital restitué <span className="text-muted">(non imposable)</span></div>
|
||||
<div className="value">{fmtEUR(bucket.capital_restitue)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.non_classe > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Distributions non classées <span className="text-muted">(nature inconnue)</span></div>
|
||||
<div className="value">{fmtEUR(bucket.non_classe)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.pertes_definitives > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Pertes définitives</div>
|
||||
<div className="value danger">{fmtEUR(bucket.pertes_definitives)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.frais_gestion > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Frais de gestion <span className="text-muted">(informatif)</span></div>
|
||||
<div className="value">{fmtEUR(bucket.frais_gestion)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && mode === 'direct' && (
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)' }}>
|
||||
<div className="kpi-grid">
|
||||
<div className="kpi">
|
||||
<div className="label">PFU sur plus-value <span className="text-muted">(IR + PS)</span></div>
|
||||
<div className="value">
|
||||
{bucket.fiscal.prelev_forfaitaire_plus_value != null
|
||||
? fmtEUR((bucket.fiscal.prelev_forfaitaire_plus_value || 0) + (bucket.fiscal.prelev_sociaux_plus_value || 0))
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">PFU sur intérêts <span className="text-muted">(IR + PS)</span></div>
|
||||
<div className="value">
|
||||
{bucket.fiscal.prelev_forfaitaire_interets != null
|
||||
? fmtEUR((bucket.fiscal.prelev_forfaitaire_interets || 0) + (bucket.fiscal.prelev_sociaux_interets || 0))
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasData && mode === 'pea_pme' && (
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)' }}>
|
||||
<div className="kpi-grid">
|
||||
<div className="kpi">
|
||||
<div className="label">Prélèvements sociaux <span className="text-muted">(17,2 %)</span></div>
|
||||
<div className="value">{bucket.fiscal.prelev_sociaux != null ? fmtEUR(bucket.fiscal.prelev_sociaux) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: 12, marginTop: 10, marginBottom: 0 }}>{bucket.fiscal.note}</p>
|
||||
</div>
|
||||
)}
|
||||
{hasData && mode === 'assurance_vie' && (
|
||||
<p className="text-muted" style={{ fontSize: 12, marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)', marginBottom: 0 }}>
|
||||
{bucket.fiscal.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TaxReportPe — Fiscalité Private Equity (chantier du 20/09/26, demande Olivier : "Nous n'avons
|
||||
* pas fait la partie Fiscalité pour les PE"). Pendant PE de TaxReport.jsx (crowdlending), mais
|
||||
* volontairement plus simple : pas d'onglets CERFA/2042/2047/2778 (aucun de ces formulaires ne
|
||||
* s'applique directement au PE — cf. commentaire taxreportPe.js), un récapitulatif par mode de
|
||||
* détention (direct / PEA-PME / assurance-vie, cf. échange avec Olivier : "j'ai besoin de
|
||||
* différencier les 3") avec un calcul PFU/PS indicatif propre à chaque régime.
|
||||
*/
|
||||
export default function TaxReportPe() {
|
||||
const { activeId, activeView } = useInvestisseur();
|
||||
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
|
||||
const [availableYears, setAvailableYears] = useState([]);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('2042');
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport-pe/years', scopeParams).then(years => {
|
||||
setAvailableYears(years);
|
||||
if (years.length > 0 && !years.map(String).includes(annee)) {
|
||||
setAnnee(String(years[0]));
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId, activeView]);
|
||||
|
||||
const load = () => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport-pe', { annee, ...scopeParams })
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line
|
||||
|
||||
const downloadCsv = () => {
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const investisseurId = localStorage.getItem('cl_investisseur_id');
|
||||
const exportParams = activeView === 'all' ? { annee, scope: 'all' } : { annee };
|
||||
fetch(api.exportUrl('/taxreport-pe/export', exportParams), {
|
||||
headers: { Authorization: `Bearer ${token}`, 'X-Investisseur-Id': investisseurId },
|
||||
})
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `fiscalite-pe-${annee}.csv`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
const hasAnyData = data && (
|
||||
['direct', 'pea_pme', 'assurance_vie'].some(m => {
|
||||
const b = data.par_mode_detention[m];
|
||||
return b.plus_value_realisee > 0 || b.interet_non_deploye > 0 || b.capital_restitue > 0
|
||||
|| b.non_classe > 0 || b.pertes_definitives > 0;
|
||||
})
|
||||
|| (data.non_rattachees && (data.non_rattachees.plus_value_realisee > 0 || data.non_rattachees.interet_non_deploye > 0))
|
||||
);
|
||||
|
||||
if (!loading && data && availableYears.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="tax" />Fiscalité — Private Equity</h2></div>
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title="Aucune donnée fiscale PE pour le moment"
|
||||
message="Vos deals PE sont configurés, mais aucune distribution n'a encore été enregistrée. Ajoutez une distribution (page Remboursements) pour voir apparaître votre récapitulatif fiscal."
|
||||
cta="Ajouter une distribution"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="tax" />Fiscalité — Private Equity</h2>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch' }}>
|
||||
<button type="button" className="icon-btn" disabled={!data} onClick={downloadCsv} title="Exporter en CSV">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || !data ? (
|
||||
<div className="card text-muted">Chargement…</div>
|
||||
) : !hasAnyData ? (
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title={`Aucune donnée fiscale PE pour ${annee}`}
|
||||
message="Aucune distribution ni perte définitive enregistrée sur cette année. Changez d'année ou ajoutez des mouvements."
|
||||
cta="Ajouter une distribution"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'stretch' }}>
|
||||
<RegimeCard mode="direct" bucket={data.par_mode_detention.direct} />
|
||||
<RegimeCard mode="pea_pme" bucket={data.par_mode_detention.pea_pme} />
|
||||
<RegimeCard mode="assurance_vie" bucket={data.par_mode_detention.assurance_vie} />
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: 10 }}>À quoi correspond chaque CERFA ?</h3>
|
||||
<div style={{ display: 'flex', gap: 28, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 280, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<InfoLigneCerfa
|
||||
code="2042"
|
||||
titre="Déclaration des revenus"
|
||||
texte="La déclaration principale : reprend la case 2TR (intérêt sur capital non déployé) et, le cas échéant, les cases 3VG / 3VH (plus ou moins-value de cession) reportées depuis la 2074 — pour vos deals en détention directe uniquement."
|
||||
/>
|
||||
<InfoLigneCerfa
|
||||
code="2561"
|
||||
titre="IFU — Imprimé fiscal unique"
|
||||
texte="Récapitulatif annuel transmis par les plateformes pour vos deals en détention directe soumis au PFU. Ne couvre que l'intérêt sur capital non déployé (case AR → 2TR) : les plus-values de cession n'y figurent jamais."
|
||||
/>
|
||||
<InfoLigneCerfa
|
||||
code="2074"
|
||||
titre="Plus ou moins-values de cession de valeurs mobilières"
|
||||
texte="À déclarer vous-même, séparément de l'IFU : la plus-value réalisée (case 3VG) ou la moins-value définitive constatée (case 3VH) sur un deal en détention directe. Le résultat net alimente ensuite la 2042."
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 280, display: 'flex', justifyContent: 'center' }}>
|
||||
<CerfaFlowDiagramPe />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted" style={{ marginTop: 14, marginBottom: 0, fontSize: 12, lineHeight: 1.6 }}>
|
||||
⚠ Ce module est une aide à la préparation de votre déclaration ; il ne la remplace pas. Les onglets et cases
|
||||
ci-dessous ne concernent que vos deals en <strong>détention directe</strong> — le PEA-PME et l'assurance-vie
|
||||
suivent des régimes fiscaux distincts, sans formulaire CERFA dédié (cf. cartes ci-dessus). Vérifiez chaque
|
||||
montant avant de le reporter. Vous restez seul responsable de l'exactitude de votre déclaration fiscale —
|
||||
Crowdlending ne saurait être tenu responsable des données déclarées.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
|
||||
<button className={`dr-tab${activeTab === '2561' ? ' active' : ''}`} onClick={() => setActiveTab('2561')}>CERFA 2561 (IFU)</button>
|
||||
</div>
|
||||
|
||||
{activeTab === '2042' && <Cerfa2042Pe annee={annee} activeView={activeView} />}
|
||||
{activeTab === '2561' && <Cerfa2561Pe annee={annee} activeView={activeView} />}
|
||||
|
||||
{data.non_rattachees && (data.non_rattachees.plus_value_realisee > 0 || data.non_rattachees.interet_non_deploye > 0 || data.non_rattachees.non_classe > 0) && (
|
||||
<div className="card" style={{ marginTop: 16, borderColor: 'var(--warning, #d97706)' }}>
|
||||
<h3 style={{ marginTop: 0 }}>⚠ Distributions non rattachées à un deal</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>
|
||||
Ces distributions n'indiquent pas de deal PE précis (champ « Investissement PE »
|
||||
du formulaire) — leur régime de détention (direct / PEA-PME / assurance-vie) ne
|
||||
peut donc pas être déterminé, et elles ne sont pas comptées dans les cartes
|
||||
ci-dessus. Rattachez-les à leur deal (page Remboursements) pour qu'elles soient
|
||||
prises en compte.
|
||||
</p>
|
||||
<div className="kpi-grid">
|
||||
{data.non_rattachees.plus_value_realisee > 0 && (
|
||||
<div className="kpi"><div className="label">Plus-value réalisée</div><div className="value">{fmtEUR(data.non_rattachees.plus_value_realisee)}</div></div>
|
||||
)}
|
||||
{data.non_rattachees.interet_non_deploye > 0 && (
|
||||
<div className="kpi"><div className="label">Intérêt sur capital non déployé</div><div className="value">{fmtEUR(data.non_rattachees.interet_non_deploye)}</div></div>
|
||||
)}
|
||||
{data.non_rattachees.non_classe > 0 && (
|
||||
<div className="kpi"><div className="label">Non classées</div><div className="value">{fmtEUR(data.non_rattachees.non_classe)}</div></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pertes_detail && data.pertes_detail.length > 0 && (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Pertes définitives déclarées en {annee}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date d'effet</th>
|
||||
<th>Deal</th>
|
||||
<th>Plateforme</th>
|
||||
<th>Mode de détention</th>
|
||||
<th>Motif</th>
|
||||
<th className="num">Montant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.pertes_detail.map((p, i) => (
|
||||
<tr key={i}>
|
||||
<td>{fmtDate(p.date_effet)}</td>
|
||||
<td>{p.nom_deal}</td>
|
||||
<td>{p.plateforme_nom}</td>
|
||||
<td>{MODE_DETENTION_LABELS[p.mode_detention] || p.mode_detention}</td>
|
||||
<td className="text-muted">{p.motif}</td>
|
||||
<td className="num">{fmtEUR(p.montant_perte)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted" style={{ fontSize: 12, marginTop: 16 }}>
|
||||
{data.cases?.note}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,26 @@ export const STATUT_LABELS = {
|
||||
/** Retourne le libellé affiché d'un statut (première lettre majuscule). */
|
||||
export const fmtStatut = (s) => STATUT_LABELS[s] ?? s;
|
||||
|
||||
/* ── Nature d'une distribution PE ──────────────────────────────
|
||||
* Extrait en constante partagée (20/09/26, chantier "Fiscalité PE") — jusqu'ici dupliquée entre
|
||||
* RemboursementsSimple.jsx (objet local) et PlateformesPe.jsx (ternaire à 2 branches) ; un seul
|
||||
* endroit à mettre à jour quand une nature est ajoutée (comme 'plus_value' ici). */
|
||||
export const DISTRIBUTION_NATURE_LABELS = {
|
||||
capital: 'Restitution de capital',
|
||||
interet_non_deploye: 'Intérêt sur capital non déployé',
|
||||
plus_value: 'Plus-value réalisée',
|
||||
};
|
||||
|
||||
/** Libellés des 3 régimes de détention d'un deal PE (investissements_pe.mode_detention) —
|
||||
* reprend exactement les libellés déjà utilisés dans InvestissementPeDetail.jsx (19/09/26),
|
||||
* extraits ici en constante partagée le 20/09/26 (chantier "Fiscalité PE", réutilisée aussi
|
||||
* par TaxReportPe.jsx). */
|
||||
export const MODE_DETENTION_LABELS = {
|
||||
direct: 'Direct',
|
||||
pea_pme: 'PEA / PEA-PME',
|
||||
assurance_vie: 'Assurance-vie',
|
||||
};
|
||||
|
||||
/** Initiales pour l'avatar (2 caractères max).
|
||||
* - Si prenom est renseigné : 1re lettre du prénom + 1re lettre du reste du nom complet
|
||||
* - Sinon (données migrées) : 1re lettre du 1er mot + 1re lettre du dernier mot du nom complet
|
||||
|
||||
Reference in new issue
Block a user