510 lines
26 KiB
React
510 lines
26 KiB
React
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import PageIcon from '../components/PageIcon.jsx';
|
|
import EmptyState from '../components/EmptyState.jsx';
|
|
import Pagination from '../components/Pagination.jsx';
|
|
import { usePagination } from '../hooks/usePagination.js';
|
|
import { api } from '../api.js';
|
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
|
import { useUi } from '../context/UiContext.jsx';
|
|
import { fmtEUR, fmtStatut } from '../utils/format.js';
|
|
import Cerfa2561Preview from '../components/Cerfa2561Preview.jsx';
|
|
import CerfaRecapTable from '../components/CerfaRecapTable.jsx';
|
|
import Cerfa2778Preview from '../components/Cerfa2778Preview.jsx';
|
|
import Cerfa2047Preview from '../components/Cerfa2047Preview.jsx';
|
|
import Cerfa2042Preview from '../components/Cerfa2042Preview.jsx';
|
|
import Cerfa2042CProPreview from '../components/Cerfa2042CProPreview.jsx';
|
|
|
|
/* ── YearSelector ────────────────────────────────────────────── */
|
|
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, i) => {
|
|
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)',
|
|
borderBottom: i < availableYears.length - 1 ? '1px solid var(--border)' : 'none',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
transition: 'background .1s',
|
|
}}
|
|
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
|
>
|
|
<span>{yr}</span>
|
|
{isActive && (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── ExportDropdown ──────────────────────────────────────────── */
|
|
function ExportDropdown({ disabled, onCSV, onJSON }) {
|
|
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]);
|
|
const choose = fn => { setOpen(false); fn(); };
|
|
return (
|
|
<div ref={ref} style={{ position: 'relative' }}>
|
|
<button type="button" className="icon-btn" disabled={disabled}
|
|
onClick={() => setOpen(o => !o)} title="Exporter">
|
|
<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>
|
|
{open && (
|
|
<div className="export-dropdown" role="menu">
|
|
<button role="menuitem" onClick={() => choose(onCSV)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
|
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
|
|
</svg>
|
|
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
|
</button>
|
|
<button role="menuitem" onClick={() => choose(onJSON)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
|
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
|
|
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
|
|
</svg>
|
|
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Ligne d'explication d'un formulaire CERFA ── */
|
|
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 ── */
|
|
function CerfaFlowDiagram({ hasEtranger }) {
|
|
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(#cerfaFlowArrow)" />
|
|
);
|
|
const markerDef = (
|
|
<defs>
|
|
<marker id="cerfaFlowArrow" 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>
|
|
);
|
|
|
|
if (!hasEtranger) {
|
|
return (
|
|
<svg viewBox="0 0 420 80" style={{ width: '100%', maxWidth: 420, height: 'auto', display: 'block' }}>
|
|
{markerDef}
|
|
{arrow(160, 40, 228, 40, 'a1')}
|
|
{box(20, 15, 140, 50, '2561', 'IFU')}
|
|
{box(230, 15, 170, 50, '2042', 'Déclaration revenus', true)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<svg viewBox="0 0 620 250" style={{ width: '100%', maxWidth: 560, height: 'auto', display: 'block' }}>
|
|
{markerDef}
|
|
{arrow(520, 58, 520, 98, 'a1')}
|
|
{arrow(100, 148, 260, 190, 'a2')}
|
|
{arrow(520, 148, 362, 190, 'a3')}
|
|
|
|
{box(440, 8, 160, 50, '2778-SD', 'PFO mensuel')}
|
|
{box(20, 98, 160, 50, '2561', 'IFU')}
|
|
{box(440, 98, 160, 50, '2047', 'Revenus étranger')}
|
|
{box(210, 190, 200, 54, '2042', 'Déclaration revenus', true)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/* ── Composant principal ─────────────────────────────────────── */
|
|
export default function TaxReport() {
|
|
const navigate = useNavigate();
|
|
const { activeId, activeView } = useInvestisseur();
|
|
const { pfoAssujetti } = useUi();
|
|
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
|
|
const [availableYears, setAvailableYears] = useState([]);
|
|
const [data, setData] = useState(null);
|
|
const [data2042, setData2042] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeTab, setActiveTab] = useState('2042');
|
|
const [detailExpanded, setDetailExpanded] = useState(false);
|
|
const [cerfaExpanded, setCerfaExpanded] = useState(false);
|
|
const [hasPlateformes, setHasPlateformes] = useState(true); // true par défaut pour éviter un flash du mauvais message
|
|
const [hasEtranger, setHasEtranger] = useState(false); // au moins une plateforme étrangère PSFP configurée (pour l'onglet CERFA 2047 — indépendant de l'année sélectionnée, même principe que le 2778-SD ; les plateformes PSAN ne sont pas concernées)
|
|
const [hasPsan, setHasPsan] = useState(false); // au moins une plateforme PSAN (crypto) configurée, quelle que soit sa domiciliation (pour l'onglet CERFA 2042 C PRO — indépendant de l'année sélectionnée, même principe que hasEtranger)
|
|
|
|
/* ── Plateformes configurées ? (pour distinguer "pas de plateforme" de "pas encore de données") ── */
|
|
useEffect(() => {
|
|
api.get('/plateformes').then(p => {
|
|
setHasPlateformes((p || []).length > 0);
|
|
setHasEtranger((p || []).some(pl => pl.domiciliation && pl.domiciliation !== 'FR' && (pl.categorie_plateforme ?? 'psfp') === 'psfp'));
|
|
setHasPsan((p || []).some(pl => (pl.categorie_plateforme ?? 'psfp') === 'psan'));
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
/* ── Chargement des années disponibles ── */
|
|
useEffect(() => {
|
|
if (!activeId && activeView !== 'all') return;
|
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
|
api.get('/taxreport/years', scopeParams).then(years => {
|
|
setAvailableYears(years);
|
|
// Si l'année courante n'est pas dans la liste, prendre la première disponible
|
|
if (years.length > 0 && !years.map(String).includes(annee)) {
|
|
setAnnee(String(years[0]));
|
|
}
|
|
});
|
|
}, [activeId, activeView]); // eslint-disable-line
|
|
|
|
const load = () => {
|
|
if (!activeId && activeView !== 'all') return;
|
|
setData(null);
|
|
setLoading(true);
|
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
|
const LS_EXCL = 'cl_2778_excluded_plats';
|
|
const excluded = new Set(JSON.parse(localStorage.getItem(LS_EXCL) ?? '[]'));
|
|
Promise.all([
|
|
api.get('/taxreport', { annee, ...scopeParams }),
|
|
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
|
|
api.get('/taxreport/2778', { annee, ...scopeParams }),
|
|
]).then(([d, d2561, d2778]) => {
|
|
setData(d);
|
|
const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
|
|
// case_2TY (pertes en capital) n'est pas spécifique FR/étranger contrairement aux autres
|
|
// cases — une perte définitive sur une plateforme étrangère doit compter aussi.
|
|
const allLignes = d2561?.lignes ?? [];
|
|
const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
|
|
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
|
|
const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo
|
|
setData2042({
|
|
case_2TT: frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0),
|
|
case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
|
|
case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)) : 0),
|
|
case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo) : 0),
|
|
case_2TY: allLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
|
|
});
|
|
}).finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(load, [activeId, activeView, annee, pfoAssujetti]); // eslint-disable-line
|
|
|
|
/* ── Pagination détail ── */
|
|
const detail = data?.detail ?? [];
|
|
const {
|
|
pagedItems: pagedDetail, page: detPage, setPage: setDetPage,
|
|
pageSize: detPageSize, setPageSize: setDetPageSize,
|
|
totalPages: detTotalPages, totalItems: detTotalItems, PAGE_SIZES,
|
|
} = usePagination(detail, 'cl_pagesize_fiscal_detail', [activeTab]);
|
|
|
|
/* ── Exports ── */
|
|
const dlBlob = (content, filename, type) => {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
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/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 = `2778-SD-${annee}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
};
|
|
|
|
const downloadJson = () => {
|
|
if (!data) return;
|
|
const payload = {
|
|
annee: data.annee,
|
|
recap: data.recap,
|
|
cases: data.cases,
|
|
detail: data.detail,
|
|
pertes: data.pertes,
|
|
pertesTotales: data.pertesTotales,
|
|
};
|
|
dlBlob(JSON.stringify(payload, null, 2), `2778-SD-${annee}.json`, 'application/json');
|
|
};
|
|
|
|
if (!loading && data && availableYears.length === 0) return (
|
|
<>
|
|
<div className="topbar"><h2>Fiscalité</h2></div>
|
|
{hasPlateformes ? (
|
|
<EmptyState
|
|
icon="📋"
|
|
title="Aucune donnée fiscale pour le moment"
|
|
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré. Ajoutez un remboursement pour voir apparaître votre récapitulatif fiscal."
|
|
cta="Ajouter un remboursement"
|
|
to="/remboursements?new=1"
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
icon="📋"
|
|
title="Aucune donnée fiscale disponible"
|
|
message="Vous n'avez pas encore de données fiscales à déclarer. Ajoutez des plateformes et des remboursements pour voir apparaître votre récapitulatif fiscal."
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
return (
|
|
<>
|
|
<div className="topbar">
|
|
<h2><PageIcon name="tax" />Fiscalité — Récapitulatif fiscal</h2>
|
|
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
|
|
</div>
|
|
|
|
{loading || !data ? (
|
|
<div className="card text-muted">Chargement…</div>
|
|
) : (
|
|
<>
|
|
{!detailExpanded && !cerfaExpanded && data2042 && (
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'stretch' }}>
|
|
<div className="card" style={{ flex: '3 1 420px' }}>
|
|
<h3 style={{ marginTop: 0 }}>Cases fiscales 2042 — synthèse {annee}</h3>
|
|
<div className="kpi-grid">
|
|
{data2042.case_2TT > 0 && <div className="kpi"><div className="label">Case 2TT — Prêts participatifs (FR)</div><div className="value">{fmtEUR(data2042.case_2TT)}</div></div>}
|
|
{data2042.case_2TR > 0 && <div className="kpi"><div className="label">Case 2TR — Revenus fixes (FR + étranger)</div><div className="value">{fmtEUR(data2042.case_2TR)}</div></div>}
|
|
<div className="kpi"><div className="label">Case 2BH — Base CSG/CRDS</div><div className="value">{fmtEUR(data2042.case_2BH)}</div></div>
|
|
<div className="kpi"><div className="label">Case 2CK — Crédit d'impôt</div><div className="value" style={{ color: 'var(--success)' }}>{fmtEUR(data2042.case_2CK)}</div></div>
|
|
{data2042.case_2TY > 0 && <div className="kpi"><div className="label">Case 2TY — Pertes en capital</div><div className="value danger">{fmtEUR(data2042.case_2TY)}</div></div>}
|
|
</div>
|
|
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
|
|
⚠ Cases indicatives combinant plateformes françaises (IFU automatique) et étrangères. Référez-vous à la notice 2041-GFI.
|
|
</p>
|
|
</div>
|
|
|
|
{hasPsan && data?.cerfa2042cpro?.donnees2042cpro && (
|
|
<div className="card" style={{ flex: '1 1 220px' }}>
|
|
<h3 style={{ marginTop: 0 }}>Cases fiscales 2042 C PRO — synthèse {annee}</h3>
|
|
<div className="kpi-grid">
|
|
<div className="kpi"><div className="label">Case 5HQ — Micro-BNC (PSAN)</div><div className="value">{fmtEUR(data.cerfa2042cpro.donnees2042cpro.case_5HQ)}</div></div>
|
|
</div>
|
|
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
|
|
⚠ Montant brut ; l'abattement de 34 % (régime micro-BNC) est appliqué automatiquement par l'administration.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!detailExpanded && !cerfaExpanded && data2042 && (
|
|
<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 les cases calculées ci-dessus (2TT, 2TR, 2BH, 2CK, 2TY)."
|
|
/>
|
|
<InfoLigneCerfa
|
|
code="2561"
|
|
titre="IFU — Imprimé fiscal unique"
|
|
texte="Récapitulatif annuel transmis par les plateformes françaises soumises au prélèvement forfaitaire unique (PFU)."
|
|
/>
|
|
{hasEtranger && (
|
|
<InfoLigneCerfa
|
|
code="2047"
|
|
titre="Revenus encaissés à l'étranger"
|
|
texte="Obligatoire dès qu'une ou plusieurs de vos plateformes sont domiciliées hors de France — détaille les intérêts perçus pays par pays et le crédit d'impôt associé."
|
|
/>
|
|
)}
|
|
{hasEtranger && (
|
|
<InfoLigneCerfa
|
|
code="2778-SD"
|
|
titre="Prélèvement forfaitaire obligatoire (PFO) - selon votre situation fiscale"
|
|
texte="Les intérêts perçus via des plateformes étrangères sont soumis à un prélèvement forfaitaire obligatoire (acompte) si vous avez un revenu fiscal de référence de l'avant-dernière année de 25 000 € pour un personne seule ou 50 000 € pour couple marié ou pacsé. Malgré la déclaration mensuelle aux autorités fiscales, la déclaration annuelle au travers du CERFA 2047 reste obligatoire."
|
|
/>
|
|
)}
|
|
{hasPsan && (
|
|
<InfoLigneCerfa
|
|
code="2042-C PRO"
|
|
titre="Intérêts d'emprunts sur une plateforme d'actif numérique (Crowdlending sur RWA)"
|
|
texte="Les intérêts perçus via des plateformes de type « Prestataire de Services sur Actifs Numériques » pour des prêts de type Crowdlending sur des RWA (Real World Asset) sont des bénéfices non commerciaux (BNC), quelle que soit leur localisation. Pas de déclaration CERFA 2047, ni de crédit d'impôt possible. Une déclaration directe en 2042-C PRO en régime micro-BNC (case 5HQ)."
|
|
/>
|
|
)}
|
|
</div>
|
|
<div style={{ flex: '1 1 320px', minWidth: 280, display: 'flex', justifyContent: 'center' }}>
|
|
<CerfaFlowDiagram hasEtranger={hasEtranger} />
|
|
</div>
|
|
</div>
|
|
|
|
{hasEtranger && (
|
|
<div style={{
|
|
marginTop: 14, padding: '12px 14px', borderRadius: 10,
|
|
background: pfoAssujetti ? 'rgba(16,185,129,0.06)' : 'rgba(245,158,11,0.08)',
|
|
border: `1px solid ${pfoAssujetti ? 'rgba(16,185,129,0.2)' : 'rgba(245,158,11,0.25)'}`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
|
}}>
|
|
<div style={{ fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
|
|
{pfoAssujetti ? (
|
|
<>Vous avez des plateformes étrangères et l'option de déclaration <strong>2778-SD</strong> est activée.</>
|
|
) : (
|
|
<>Vous avez des plateformes étrangères. Vérifiez si la déclaration mensuelle <strong>2778-SD</strong> s'applique à votre situation.</>
|
|
)}
|
|
</div>
|
|
<button className="btn btn-sm btn-primary" onClick={() => navigate('/settings?section=ma-fiscalite')}>
|
|
{pfoAssujetti ? 'Gérer dans les paramètres' : 'Vérifier dans les paramètres'}
|
|
</button>
|
|
</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. 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>
|
|
)}
|
|
|
|
{!detailExpanded && !cerfaExpanded && (
|
|
<div className="dr-tabs">
|
|
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
|
|
{hasPsan && <button className={`dr-tab${activeTab === '2042cpro' ? ' active' : ''}`} onClick={() => setActiveTab('2042cpro')}>CERFA 2042 C PRO</button>}
|
|
<button className={`dr-tab${activeTab === 'cerfa' ? ' active' : ''}`} onClick={() => setActiveTab('cerfa')}>CERFA 2561 (IFU)</button>
|
|
{hasEtranger && <button className={`dr-tab${activeTab === '2047' ? ' active' : ''}`} onClick={() => setActiveTab('2047')}>CERFA 2047</button>}
|
|
{pfoAssujetti && <button className={`dr-tab${activeTab === '2778' ? ' active' : ''}`} onClick={() => setActiveTab('2778')}>CERFA 2778-SD</button>}
|
|
</div>
|
|
)}
|
|
|
|
</>
|
|
)}
|
|
|
|
{activeTab === '2042' && !detailExpanded && !cerfaExpanded && data && (
|
|
<Cerfa2042Preview annee={annee} activeView={activeView} pfoAssujetti={pfoAssujetti} />
|
|
)}
|
|
|
|
{(activeTab === 'cerfa' || cerfaExpanded) && !detailExpanded && data && (
|
|
<Cerfa2561Preview
|
|
annee={annee} activeView={activeView} inline
|
|
expanded={cerfaExpanded}
|
|
onToggleExpand={() => { setCerfaExpanded(e => !e); setActiveTab('cerfa'); }}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === '2047' && hasEtranger && !detailExpanded && !cerfaExpanded && (
|
|
<Cerfa2047Preview annee={annee} activeView={activeView} />
|
|
)}
|
|
|
|
{activeTab === '2778' && pfoAssujetti && !detailExpanded && !cerfaExpanded && (
|
|
<Cerfa2778Preview annee={annee} activeView={activeView} />
|
|
)}
|
|
|
|
{activeTab === '2042cpro' && hasPsan && !detailExpanded && !cerfaExpanded && (
|
|
<Cerfa2042CProPreview annee={annee} activeView={activeView} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|