Amélioration de la fonctionnalité Fiscalité

This commit is contained in:
ocroguennec committed 2026-08-22 11:58:52 +02:00
1 parent 5437091884
commit 5dc6f21e61
7 files changed
+1184 -167

No files matched your search

+169
View File
@@ -623,4 +623,173 @@ router.get('/2778', (req, res) => {
res.json({ annee, plateformes }); res.json({ annee, plateformes });
}); });
/* ── 2047 — Cadre 2, rubrique 230 (Intérêts) — vision annuelle par pays/plateforme ──
Détermine, pour chaque plateforme étrangère × investisseur, si les intérêts perçus
ouvrent droit à un crédit d'impôt conventionnel (cases 231-238) ou non (case 250),
en s'appuyant sur le référentiel pays "taux_credit_impot" (paramétrage admin).
Éligibilité = convention active + taux d'intérêt renseigné + non "exclusif résidence"
(le pictogramme c/ de la notice signifie imposable exclusivement en France : aucun
crédit d'impôt possible, même si la plateforme a réellement prélevé une taxe locale). ── */
router.get('/2047', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.query.scope === 'all';
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
const rows = db.prepare(`
SELECT
p.id AS plateforme_id, p.nom AS plateforme_nom, p.domiciliation,
p.fiscalite, p.taux_fiscalite_locale,
inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
COALESCE(SUM(
CASE WHEN r.interets_bruts_avant_local IS NOT NULL AND r.interets_bruts_avant_local > 0
THEN r.interets_bruts_avant_local ELSE r.interets_bruts END
), 0) AS montant_avant_local,
COALESCE(SUM(r.interets_bruts), 0) AS montant_net,
COALESCE(SUM(r.taxe_locale), 0) AS impot_local
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.domiciliation != 'FR'
GROUP BY p.id, inv.id
`).all(invArg, annee);
// Intérêts plateforme (bonus, sans investissement rattaché) — même logique de fusion que /2778
const invCondBonus2047 = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const rowsInteretsPlateforme = db.prepare(`
SELECT
p.id AS plateforme_id, p.nom AS plateforme_nom, p.domiciliation,
p.fiscalite, p.taux_fiscalite_locale,
inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
COALESCE(SUM(
CASE WHEN r.interets_bruts_avant_local IS NOT NULL AND r.interets_bruts_avant_local > 0
THEN r.interets_bruts_avant_local ELSE r.interets_bruts END
), 0) AS montant_avant_local,
COALESCE(SUM(r.interets_bruts), 0) AS montant_net,
COALESCE(SUM(r.taxe_locale), 0) AS impot_local
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
WHERE ${invCondBonus2047}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.domiciliation != 'FR'
GROUP BY p.id, inv.id
`).all(invArg, annee);
const rowsMap2047 = {};
for (const r0 of rows) rowsMap2047[`${r0.plateforme_id}_${r0.investisseur_id}`] = { ...r0 };
for (const r0 of rowsInteretsPlateforme) {
const key = `${r0.plateforme_id}_${r0.investisseur_id}`;
if (rowsMap2047[key]) {
rowsMap2047[key].montant_avant_local += r0.montant_avant_local;
rowsMap2047[key].montant_net += r0.montant_net;
rowsMap2047[key].impot_local += r0.impot_local;
} else {
rowsMap2047[key] = { ...r0 };
}
}
const rowsMerged2047 = Object.values(rowsMap2047).sort((a, b) =>
a.plateforme_nom.localeCompare(b.plateforme_nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
// Référentiel pays (taux crédit d'impôt) — jointure par code pays (= plateformes.domiciliation)
const paysRows = db.prepare('SELECT * FROM taux_credit_impot').all();
const paysMap = {};
for (const p of paysRows) {
if (p.code_pays) paysMap[p.code_pays.trim().toUpperCase()] = p;
}
const lignes = rowsMerged2047.map(r0 => {
const pays = paysMap[(r0.domiciliation || '').trim().toUpperCase()] || null;
// Règle fiscale : les montants portés sur les cases d'une déclaration sont arrondis à
// l'euro le plus proche (même convention que les cases BA/IA/QR du 2778-SD — cf. computeCases
// côté frontend). On arrondit donc ici la base (net encaissé / impôt local) avant d'en dériver
// les cases 235/237/238, pour rester cohérent avec le mode de calcul du 2778-SD.
const montantNet = Math.round(r0.montant_net);
const impotLocal = Math.round(r0.impot_local);
// Éligible aux cases 231-238 seulement si DEUX conditions sont réunies :
// 1) le pays ouvre droit à un crédit d'impôt (convention active, non "exclusif résidence" —
// pictogramme c/ = imposable exclusivement en France, donc aucun crédit possible) ;
// 2) la plateforme a réellement prélevé un impôt à la source sur l'année (impotLocal > 0).
// Sans retenue réelle, il n'y a rien à créditer : la ligne va en case 250 avec son montant
// net intégral (= montant brut, puisqu'aucun impôt étranger n'a été prélevé dessus), même
// si le pays serait par ailleurs éligible (ex. plateforme "sans fiscalité locale").
const paysEligible = !!pays
&& pays.statut_convention === 'active'
&& !pays.int_exclusif_residence;
const eligible = paysEligible && impotLocal > 0;
// Certains pays (ex. Bénin, Saint-Barthélemy) n'ont pas de taux forfaitaire chiffré dans la
// notice ("le crédit d'impôt est égal à l'impôt prélevé", sans plafond en %) : le crédit est
// alors non plafonné et égal à l'impôt réellement supporté à l'étranger.
const tauxPlafonne = eligible && pays.int_taux != null;
const tauxApplicable = tauxPlafonne ? pays.int_taux : null;
const resultat = tauxPlafonne ? Math.round(montantNet * pays.int_taux / 100) : (eligible ? impotLocal : 0);
const creditRetenu = eligible ? Math.round(Math.min(resultat, impotLocal)) : 0;
const interetsInclus = eligible ? montantNet + creditRetenu : 0;
let motif = null;
if (!eligible) {
if (!pays) motif = "Aucune convention fiscale identifiée pour ce pays";
else if (pays.statut_convention !== 'active') motif = `Convention fiscale ${pays.statut_convention} avec ce pays`;
else if (pays.int_exclusif_residence) motif = "Revenus imposables exclusivement en France (pas de crédit d'impôt)";
else motif = "Aucune retenue à la source appliquée par cette plateforme (rien à créditer)";
}
return {
plateforme_id: r0.plateforme_id,
plateforme_nom: r0.plateforme_nom,
domiciliation: r0.domiciliation,
investisseur_id: r0.investisseur_id,
investisseur_nom: r0.investisseur_nom,
investisseur_prenom: r0.investisseur_prenom,
pays_nom: pays?.nom_pays ?? null,
statut_convention: pays?.statut_convention ?? null,
eligible_ci: eligible,
taux_plafonne: tauxPlafonne,
motif,
montant_avant_local: round2(r0.montant_avant_local),
case_233: eligible ? montantNet : 0,
case_234: tauxApplicable,
case_235: resultat,
case_236: eligible ? impotLocal : 0,
case_237: creditRetenu,
case_238: interetsInclus,
case_250: eligible ? 0 : montantNet,
};
});
const totals = lignes.reduce((acc, l) => ({
montant_avant_local: acc.montant_avant_local + l.montant_avant_local,
case_233: acc.case_233 + l.case_233,
case_236: acc.case_236 + l.case_236,
case_237: acc.case_237 + l.case_237,
case_238: acc.case_238 + l.case_238,
case_250: acc.case_250 + l.case_250,
}), { montant_avant_local: 0, case_233: 0, case_236: 0, case_237: 0, case_238: 0, case_250: 0 });
totals.case_251 = totals.case_238 + totals.case_250; // Total des intérêts imposables (238+240+250, 240 non utilisé ici)
totals.case_252 = totals.case_251; // dont intérêts et produits de placement à revenu fixe → 2042 case 2TR
totals.case_8VL = totals.case_237; // Crédit d'impôt conventionnel total (cadre 7) → 2042C case 8VL
totals.case_8PL = totals.case_233; // Montant net ouvrant droit à crédit d'impôt étranger → 2042C case 8PL
// case_233/236/237/238/250/251/252/8VL/8PL sont déjà des entiers (euros) — seul montant_avant_local
// (info complémentaire, non reportée sur une case du formulaire) reste arrondi au centime.
totals.montant_avant_local = round2(totals.montant_avant_local);
res.json({ annee, lignes, totals });
});
export default router; export default router;
+41 -3
View File
@@ -92,6 +92,10 @@ export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) {
const totalTaxRate = rates.pfo + rates.csg + rates.crds + rates.solidarite; const totalTaxRate = rates.pfo + rates.csg + rates.crds + rates.solidarite;
/* ── Suivi mensuel combiné ── */ /* ── Suivi mensuel combiné ── */
const totalPrelevFR = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, mo) => ss + (mo.prelev_sociaux ?? 0) + (mo.prelev_forfaitaire ?? 0), 0)), 0));
const totalPrelevEtr = r2(platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)) * totalTaxRate, 0));
const totalDejaPreleve = r2(totalPrelevFR + (pfoAssujetti ? totalPrelevEtr : 0));
const totalAPrelever = pfoAssujetti ? 0 : totalPrelevEtr;
const matriceView = ( const matriceView = (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
{loading && <div className="card text-muted">Chargement…</div>} {loading && <div className="card text-muted">Chargement…</div>}
@@ -218,6 +222,40 @@ export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) {
{(totalTaxRate * 100).toFixed(1)} % {(totalTaxRate * 100).toFixed(1)} %
</td> </td>
</tr> </tr>
{platEtr.length > 0 && (
<>
{/* Montant des prélèvements déjà prélevés */}
<tr style={{ background: 'transparent' }}>
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
Montant des prélèvements déjà prélevés
</td>
{MOIS_NUMS.map(m => {
const prelevFR = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0));
const prelevEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * totalTaxRate);
const dejaPreleve = r2(prelevFR + (pfoAssujetti ? prelevEtr : 0));
return <td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>{dejaPreleve > 0 ? fmtN(dejaPreleve) + ' €' : '—'}</td>;
})}
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)' }}>
{fmtN(totalDejaPreleve)} €
</td>
</tr>
{/* Montant des prélèvements à prélever */}
<tr style={{ background: 'transparent' }}>
<td style={{ padding: '4px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>
Montant des prélèvements à prélever
{pfoAssujetti && <span style={{ marginLeft: 6, fontStyle: 'italic' }}>(couvert par la 2778-SD)</span>}
</td>
{MOIS_NUMS.map(m => {
const prelevEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * totalTaxRate);
const aPrelever = pfoAssujetti ? 0 : prelevEtr;
return <td key={m} style={{ padding: '4px 8px', textAlign: 'right', fontSize: 'var(--fs-xs)', color: aPrelever > 0 ? 'var(--danger)' : 'var(--text-muted)' }}>{aPrelever > 0 ? fmtN(aPrelever) + ' €' : '—'}</td>;
})}
<td style={{ padding: '4px 12px', textAlign: 'right', fontSize: 'var(--fs-xs)', fontWeight: 600, color: totalAPrelever > 0 ? 'var(--danger)' : 'var(--text-muted)' }}>
{fmtN(totalAPrelever)} €
</td>
</tr>
</>
)}
{/* Total intérêt net */} {/* Total intérêt net */}
<tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}> <tr style={{ background: 'rgba(22,163,74,0.06)', borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td> <td style={{ padding: '6px 12px', fontWeight: 700, color: 'var(--success)' }}>Total intérêt net</td>
@@ -284,7 +322,7 @@ export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) {
]); ]);
const total2BH = bd2BH.reduce((s, i) => s + i.val, 0); const total2BH = bd2BH.reduce((s, i) => s + i.val, 0);
// Breakdown 2CK : FR (PFNL retenu) + étrangères (acompte 2778-SD) // Breakdown 2CK : FR (PFNL retenu) + étrangères (case 273 du 2047, alimentée par les acomptes 2778-SD)
const bd2CK = applyFilter([ const bd2CK = applyFilter([
...frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK, badge: BADGE_AUTO })), ...frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK, badge: BADGE_AUTO })),
...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p) * rates.pfo), badge: BADGE_DECL })), ...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p) * rates.pfo), badge: BADGE_DECL })),
@@ -313,13 +351,13 @@ export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) {
<Case2042 code="2TT" label="Produits des prêts participatifs" note="Intérêts bruts — plateformes françaises (financement participatif, case KR)" value={total2TT || undefined} breakdown={bd2TT} /> <Case2042 code="2TT" label="Produits des prêts participatifs" note="Intérêts bruts — plateformes françaises (financement participatif, case KR)" value={total2TT || undefined} breakdown={bd2TT} />
<Case2042 code="2TR" label="Produits de placement à revenu fixe" note="Intérêts bruts — plateformes françaises (case AR) + étrangères (base imposable BA)" value={total2TR || undefined} breakdown={bd2TR} /> <Case2042 code="2TR" label="Produits de placement à revenu fixe" note="Intérêts bruts — plateformes françaises (case AR) + étrangères (base imposable BA)" value={total2TR || undefined} breakdown={bd2TR} />
<Case2042 code="2BH" label="Produits pour lesquels les PS ont déjà été appliqués" note="Même montant que 2TT/2TR — neutralise la double imposition aux prélèvements sociaux" value={total2BH || undefined} breakdown={bd2BH} /> <Case2042 code="2BH" label="Produits pour lesquels les PS ont déjà été appliqués" note="Même montant que 2TT/2TR — neutralise la double imposition aux prélèvements sociaux" value={total2BH || undefined} breakdown={bd2BH} />
<Case2042 code="2CK" label="Crédit d'impôt — prélèvement forfaitaire déjà retenu" note={`FR : PFNL retenu à la source (12,8 %) · Étranger : acompte versé via 2778-SD (${(rates.pfo*100).toFixed(1)} %)`} value={total2CK || undefined} breakdown={bd2CK} /> <Case2042 code="2CK" label="Crédit d'impôt — prélèvement forfaitaire déjà retenu" note={`FR : PFNL retenu à la source (12,8 %) · Étranger : case 273 du 2047 (${(rates.pfo*100).toFixed(1)} %)`} value={total2CK || undefined} breakdown={bd2CK} />
<Case2042 code="2TY" label="Pertes en capital sur prêts participatifs" note="Capital non remboursé sur prêts en défaut — plateformes françaises" value={total2TY || undefined} breakdown={bd2TY} /> <Case2042 code="2TY" label="Pertes en capital sur prêts participatifs" note="Capital non remboursé sur prêts en défaut — plateformes françaises" value={total2TY || undefined} breakdown={bd2TY} />
</div> </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)' }}> <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> <strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Synthèse fiscale</strong>
Les montants <BadgeTag badge={BADGE_AUTO} /> sont automatiquement reportés par la plateforme (IFU). Les montants <BadgeTag badge={BADGE_AUTO} /> sont automatiquement reportés par la plateforme (IFU).
Les montants <BadgeTag badge={BADGE_DECL} /> nécessitent une déclaration mensuelle 2778-SD et un report manuel sur la 2042. Les montants <BadgeTag badge={BADGE_DECL} /> nécessitent une déclaration annuelle 2047 et un report manuel sur la 2042.
</div> </div>
</div> </div>
); );
@@ -0,0 +1,447 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useUi } from '../context/UiContext.jsx';
import {
SuiviMensuelMatrix, Report2042Block,
computeCases, getRatesForYear, MOIS_NUMS, r,
} from './Cerfa2778Preview.jsx';
const fmtPct = (v, nonPlafonne) => v == null
? (nonPlafonne
? <span title="Convention active sans taux forfaitaire chiffré : crédit égal à l'impôt réellement prélevé, sans plafond" style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Non plafonné</span>
: <span style={{ color: 'var(--text-muted)' }}>—</span>)
: `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %`;
// Cases CERFA 2047 : montants arrondis à l'euro le plus proche (règle fiscale), sans décimales —
// contrairement au Suivi mensuel (intermédiaire, non déclaré tel quel) qui garde 2 décimales.
const fmt0 = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>- €</span>;
return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €`;
};
/* ── Badge de case CERFA — même habillage que Case2042Row / Case2042 (Report 2042) ── */
function CaseBadge({ code, tone }) {
const success = tone === 'success';
return (
<span style={{
display: 'inline-block', fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-xs)',
color: success ? 'var(--success)' : 'var(--primary)',
background: success ? 'rgba(16,185,129,0.12)' : 'rgba(99,102,241,0.07)',
borderRadius: 6, padding: '2px 6px', letterSpacing: '.04em', whiteSpace: 'nowrap',
}}>{code}</span>
);
}
function CaseHeader({ code, label, tone }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<CaseBadge code={code} tone={tone} />
<span>{label}</span>
</div>
);
}
/* ── Composant principal ── */
export default function Cerfa2047Preview({ annee, activeView }) {
const LS_KEY = 'cl_2778_excluded_plats'; // partagé avec le 2778-SD (mêmes plateformes étrangères)
const { pfoAssujetti } = useUi();
const [data2778, setData2778] = useState(null);
const [data2047, setData2047] = useState(null);
const [loading, setLoading] = useState(false);
const [pfuList, setPfuList] = useState([]);
const [excluded, setExcluded] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_KEY)) ?? []); }
catch { return new Set(); }
});
const [view, setView] = useState('matrice'); // 'matrice' | 'donnees' | 'report2042'
useEffect(() => {
setLoading(true);
setData2778(null);
setData2047(null);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
Promise.all([
api.get('/taxreport/2778', { annee, ...scopeParams }),
api.get('/taxreport/2047', { annee, ...scopeParams }),
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
]).then(([d2778, d2047, pfu]) => {
setData2778(d2778);
setData2047(d2047);
if (pfu) setPfuList(pfu);
}).finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
if (loading || !data2778 || !data2047) return <div className="card text-muted">Chargement…</div>;
const plateformes = data2778.plateformes;
const rates = getRatesForYear(annee, pfuList);
const TOTAL_TAX = rates.pfo + rates.csg + rates.crds + rates.solidarite;
if (plateformes.length === 0) {
return (
<div className="card">
<p className="text-muted" style={{ margin: 0 }}>
Aucun revenu de plateforme étrangère pour {annee}.
</p>
</div>
);
}
const togglePlat = (id) => {
setExcluded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
localStorage.setItem(LS_KEY, JSON.stringify([...next]));
return next;
});
};
const totauxMois = MOIS_NUMS.map(m => {
let sum = 0;
for (const plat of plateformes) {
if (!excluded.has(plat.id)) sum += plat.mois[m] ?? 0;
}
return r(sum);
});
const totalBA = r(totauxMois.reduce((a, b) => a + b, 0));
const totalIA = computeCases(totalBA, rates).IA;
// Lignes 2047 filtrées sur les plateformes non exclues (cohérence avec le Suivi mensuel)
const lignes2047 = data2047.lignes.filter(l => !excluded.has(`${l.plateforme_id}_${l.investisseur_id}`));
const lignesEligibles = lignes2047.filter(l => l.eligible_ci);
const lignesNonEligibles = lignes2047.filter(l => !l.eligible_ci);
// Regroupement par pays pour faciliter le report dans la déclaration 2047
// (le formulaire officiel attend une ligne par pays, pas par plateforme)
const groupesEligibles = Object.values(
lignesEligibles.reduce((acc, l) => {
const key = l.domiciliation || l.pays_nom || '?';
if (!acc[key]) acc[key] = { pays_nom: l.pays_nom, domiciliation: l.domiciliation, lignes: [] };
acc[key].lignes.push(l);
return acc;
}, {})
).sort((a, b) => (a.pays_nom ?? '').localeCompare(b.pays_nom ?? ''));
const t = lignes2047.reduce((acc, l) => ({
case_233: acc.case_233 + l.case_233,
case_236: acc.case_236 + l.case_236,
case_237: acc.case_237 + l.case_237,
case_238: acc.case_238 + l.case_238,
case_250: acc.case_250 + l.case_250,
}), { case_233: 0, case_236: 0, case_237: 0, case_238: 0, case_250: 0 });
t.case_251 = r(t.case_238 + t.case_250);
t.case_8VL = r(t.case_237);
t.case_8PL = r(t.case_233);
const platLabel = (l) => {
const n = l.investisseur_nom ?? '';
const p = l.investisseur_prenom ?? '';
const who = p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' ');
return (
<>
<span style={{ fontWeight: 500 }}>{l.plateforme_nom}</span>
{who && <span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6 }}>— {who}</span>}
</>
);
};
const cellStyle = {
textAlign: 'right', padding: '7px 10px', fontSize: 'var(--fs-sm)',
whiteSpace: 'nowrap', borderBottom: '1px solid var(--border)',
};
const headStyle = {
textAlign: 'center', padding: '7px 10px', fontSize: 'var(--fs-xs)', fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--surface-2)',
borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap',
};
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 12h6m-6 4h6m2 5H7a2 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 2047
</button>
<button
className={`account-nav-item${view === 'report2042' ? ' active' : ''}`}
onClick={() => setView('report2042')}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'/>
</svg>
Report 2042
</button>
<div style={{ margin: '10px 12px 6px', borderTop: '1px solid var(--border)' }} />
<div style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.5 }}>
Plateformes non-françaises<br/>Cadre 2, rubrique 230 — Intérêts
</div>
</nav>
<div style={{ flex: 1, minWidth: 0 }}>
{/* ── Vue Matrice (partagée avec le 2778-SD) ── */}
{view === 'matrice' && (
<SuiviMensuelMatrix
annee={annee}
plateformes={plateformes}
excluded={excluded}
togglePlat={togglePlat}
totauxMois={totauxMois}
rates={rates}
TOTAL_TAX={TOTAL_TAX}
/>
)}
{/* ── Vue Données 2047 ── */}
{view === 'donnees' && (
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<div style={{ padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Formulaire 2047 — Rubrique 230 (Intérêts)</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Vision annuelle {annee} — plateformes étrangères</span>
</div>
{/* Lignes 231-238 : éligibles au crédit d'impôt */}
<div style={{ padding: '0 14px 6px' }}>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="231-238" /> Intérêts ouvrant droit à un crédit d'impôt égal à l'impôt payé à l'étranger
</div>
</div>
{lignesEligibles.length === 0 ? (
<div style={{ padding: '4px 14px 14px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Aucune plateforme éligible pour {annee}.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr>
<th style={{ ...headStyle, textAlign: 'left' }}>Plateforme — Détenteur</th>
<th style={headStyle}>Pays</th>
<th style={headStyle}><CaseHeader code="233" label="Montant net encaissé" /></th>
<th style={headStyle}><CaseHeader code="234" label="Taux applicable" /></th>
<th style={headStyle}><CaseHeader code="235" label="Résultat" /></th>
<th style={headStyle}><CaseHeader code="236" label="Impôt supporté à l'étranger" /></th>
<th style={{ ...headStyle, background: 'rgba(16,185,129,0.10)' }}><CaseHeader code="237" label="Crédit d'impôt retenu" tone="success" /></th>
<th style={headStyle}><CaseHeader code="238" label="Intérêts crédit d'impôt inclus" /></th>
</tr>
</thead>
<tbody>
{groupesEligibles.flatMap(g => {
const sub = g.lignes.reduce((acc, l) => ({
case_233: acc.case_233 + l.case_233,
case_235: acc.case_235 + l.case_235,
case_236: acc.case_236 + l.case_236,
case_237: acc.case_237 + l.case_237,
case_238: acc.case_238 + l.case_238,
}), { case_233: 0, case_235: 0, case_236: 0, case_237: 0, case_238: 0 });
const rows = g.lignes.map(l => (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`}>
<td style={{ ...cellStyle, textAlign: 'left' }}>{platLabel(l)}</td>
<td style={{ ...cellStyle, textAlign: 'center' }}>{l.pays_nom}</td>
<td style={cellStyle}>{fmt0(l.case_233)}</td>
<td style={{ ...cellStyle, textAlign: 'center' }}>{fmtPct(l.case_234, !l.taux_plafonne)}</td>
<td style={cellStyle}>{fmt0(l.case_235)}</td>
<td style={cellStyle}>{fmt0(l.case_236)}</td>
<td style={{ ...cellStyle, fontWeight: 600, color: 'var(--success)', background: 'rgba(16,185,129,0.05)' }}>{fmt0(l.case_237)}</td>
<td style={{ ...cellStyle, fontWeight: 600 }}>{fmt0(l.case_238)}</td>
</tr>
));
if (g.lignes.length > 1) {
rows.push(
<tr key={`sous-total-${g.domiciliation}`} style={{ background: 'rgba(99,102,241,0.06)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontStyle: 'italic', color: 'var(--text-muted)' }} colSpan={2}>
Sous-total {g.pays_nom} ({g.lignes.length} plateformes)
</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_233)}</td>
<td style={cellStyle}></td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_235)}</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_236)}</td>
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>{fmt0(sub.case_237)}</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_238)}</td>
</tr>
);
}
return rows;
})}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 700 }} colSpan={2}>Total</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_233)}</td>
<td style={cellStyle}></td>
<td style={cellStyle}></td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_236)}</td>
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>{fmt0(t.case_237)}</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_238)}</td>
</tr>
</tbody>
</table>
)}
{/* Ligne 250 : n'ouvrant pas droit à crédit d'impôt */}
<div style={{ padding: '18px 14px 6px', borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="250" /> Intérêts n'ouvrant pas droit à crédit d'impôt
</div>
</div>
{lignesNonEligibles.length === 0 ? (
<div style={{ padding: '4px 14px 14px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Aucune plateforme dans ce cas pour {annee}.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr>
<th style={{ ...headStyle, textAlign: 'left' }}>Plateforme — Détenteur</th>
<th style={headStyle}>Pays</th>
<th style={{ ...headStyle, textAlign: 'left' }}>Motif</th>
<th style={headStyle}><CaseHeader code="250" label="Montant" /></th>
</tr>
</thead>
<tbody>
{lignesNonEligibles.map(l => (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`}>
<td style={{ ...cellStyle, textAlign: 'left' }}>{platLabel(l)}</td>
<td style={{ ...cellStyle, textAlign: 'center' }}>{l.domiciliation}</td>
<td style={{ ...cellStyle, textAlign: 'left', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>{l.motif}</td>
<td style={cellStyle}>{fmt0(l.case_250)}</td>
</tr>
))}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 700 }} colSpan={3}>Total</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_250)}</td>
</tr>
</tbody>
</table>
)}
{/* Totaux 251 / 252 */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 14px', marginTop: 4, borderTop: '2px solid var(--border)',
background: 'rgba(99,102,241,0.05)',
}}>
<div>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="251" /> Total des intérêts imposables (238 + 250)
</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="252" /> dont intérêts et autres produits de placement à revenu fixe → reporté en case <strong>2TR</strong> de la 2042 (voir onglet « Report 2042 »)
</div>
</div>
<div style={{ fontWeight: 800, fontSize: '1.2rem' }}>{fmt0(t.case_251)}</div>
</div>
<div style={{ padding: '10px 14px 14px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
L'éligibilité au crédit d'impôt (231-238 vs 250) est déterminée automatiquement à partir du pays de domiciliation
de chaque plateforme et du référentiel <strong>« 2047 — Taux crédit d'impôts »</strong> (Admin&nbsp;&gt;&nbsp;Fiscalité).
Le crédit d'impôt retenu (237) est plafonné au plus petit des deux montants : taux conventionnel × montant net (235),
ou impôt réellement prélevé à l'étranger (236).
</div>
{/* Rubrique 9 : revenus déjà soumis aux prélèvements sociaux (2778-SD) */}
<div style={{ padding: '18px 14px 6px', borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="272-273" /> Rubrique 9 — Revenus déjà soumis aux prélèvements sociaux (2778-SD)
</div>
</div>
{pfoAssujetti ? (
<div style={{ padding: '0 14px 14px' }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<Case2042Row
code="272"
label="Revenus déjà soumis aux prélèvements sociaux (avec CSG déductible si option barème)"
note="Même montant que la case 2BH — intérêts bruts totaux avant retenue locale, déclarés via vos 2778-SD"
value={totalBA}
/>
<Case2042Row
code="273"
label="Prélèvement forfaitaire non libératoire déjà versé"
note="Même montant que la case 2CK — somme des acomptes PFO (case IA) versés via vos 2778-SD"
value={totalIA}
/>
</div>
</div>
) : (
<div style={{
margin: '0 14px 14px', padding: '10px 14px', borderRadius: 8,
background: 'var(--surface-2)', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.6,
}}>
Les cases 272 et 273 ne s'appliquent que si l'option de déclaration 2778-SD est activée
(Paramétrage&nbsp;&gt;&nbsp;Ma fiscalité).
</div>
)}
</div>
)}
{/* ── Vue Report 2042 ── */}
{view === 'report2042' && (
<>
<Report2042Block annee={annee} totauxMois={totauxMois} rates={rates} plateformes={plateformes} excluded={excluded} source="2047" />
<div className="card" style={{ marginTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Crédit d'impôt étranger (cadre 7 du 2047)</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>À reporter sur la 2042C</span>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<Case2042Row
code="8VL"
label="Crédit d'impôt sur revenus de valeurs mobilières et assimilés"
note="Somme des crédits d'impôt retenus (case 237) — plateformes dont le pays ouvre droit à un crédit d'impôt conventionnel"
value={t.case_8VL}
/>
<Case2042Row
code="8PL"
label="Revenus nets ouvrant droit à un crédit d'impôt étranger"
note="Somme des montants nets encaissés (case 233) sur ces mêmes plateformes"
value={t.case_8PL}
/>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}
/* ── Ligne de case CERFA 2047 → report 2042/2042C ── */
function Case2042Row({ code, label, note, value }) {
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 }}>{label}</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)' }}>
{Number(value ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
</div>
</div>
</div>
);
}
+213 -159
View File
@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { useUi } from '../context/UiContext.jsx';
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; export 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']; export const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
// Taux par défaut (2026+) — remplacés par les données de /api/pfu // Taux par défaut (2026+) — remplacés par les données de /api/pfu
const DEFAULT_RATES = { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 }; const DEFAULT_RATES = { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 };
function getRatesForYear(annee, pfuList) { export function getRatesForYear(annee, pfuList) {
const yr = Number(annee); const yr = Number(annee);
// Chercher l'année exacte, puis l'année précédente la plus proche // Chercher l'année exacte, puis l'année précédente la plus proche
const sorted = [...pfuList].sort((a, b) => b.annee - a.annee); const sorted = [...pfuList].sort((a, b) => b.annee - a.annee);
@@ -21,18 +22,18 @@ function getRatesForYear(annee, pfuList) {
}; };
} }
const r = v => Math.round((v ?? 0) * 100) / 100; export const r = v => Math.round((v ?? 0) * 100) / 100;
const fmtEUR = v => { export const fmtEUR = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>- €</span>; if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>- €</span>;
return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`; return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
}; };
const fmtInt = v => { export const fmtInt = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>-</span>; if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>-</span>;
return `${v} €`; return `${v} €`;
}; };
/* ── Simulation cases CERFA pour un montant brut ── */ /* ── Simulation cases CERFA pour un montant brut ── */
function computeCases(ba, rates) { export function computeCases(ba, rates) {
const R = rates ?? DEFAULT_RATES; const R = rates ?? DEFAULT_RATES;
const BA = Math.round(ba); const BA = Math.round(ba);
const IA = Math.round(BA * R.pfo); const IA = Math.round(BA * R.pfo);
@@ -137,24 +138,6 @@ export default function Cerfa2778Preview({ annee, activeView }) {
}); });
}; };
const cellStyle = {
textAlign: 'right',
padding: '6px 10px',
fontSize: 'var(--fs-sm)',
whiteSpace: 'nowrap',
borderBottom: '1px solid var(--border)',
};
const headStyle = {
textAlign: 'center',
padding: '6px 10px',
fontSize: 'var(--fs-xs)',
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--surface-2)',
borderBottom: '1px solid var(--border)',
whiteSpace: 'nowrap',
};
return ( return (
<div className="card"> <div className="card">
{/* ── Navigation sidebar ── */} {/* ── Navigation sidebar ── */}
@@ -202,135 +185,15 @@ export default function Cerfa2778Preview({ annee, activeView }) {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{/* ── Vue Matrice ── */} {/* ── Vue Matrice ── */}
{view === 'matrice' && ( {view === 'matrice' && (
<> <SuiviMensuelMatrix
<div className="card" style={{ padding: 0, overflow: 'auto' }}> annee={annee}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}> plateformes={plateformes}
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4> excluded={excluded}
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} — plateformes étrangères</span> togglePlat={togglePlat}
</div> totauxMois={totauxMois}
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}> rates={rates}
<thead> TOTAL_TAX={TOTAL_TAX}
<tr>
<th style={{ ...headStyle, textAlign: 'left', minWidth: 180, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Plateforme — Détenteur
</th>
{MOIS_LABELS.map((m, i) => (
<th key={m} style={headStyle}>{m}</th>
))}
<th style={{ ...headStyle, background: 'var(--surface-2)' }}>Total</th>
</tr>
</thead>
<tbody>
{plateformes.map(plat => {
const isExcluded = excluded.has(plat.id);
const total = Object.values(plat.mois).reduce((a, b) => a + b, 0);
return (
<tr key={plat.id} style={{ opacity: isExcluded ? 0.4 : 1, transition: 'opacity .15s' }}>
<td style={{
...cellStyle, textAlign: 'left',
position: 'sticky', left: 0,
background: 'var(--surface)',
fontWeight: 500,
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 'var(--fs-sm)', textTransform: 'none', letterSpacing: 'normal', color: 'var(--text)', fontWeight: 400, marginBottom: 0 }}>
<input
type="checkbox"
checked={!isExcluded}
onChange={() => togglePlat(plat.id)}
style={{ accentColor: 'var(--primary)', width: 14, height: 14, flexShrink: 0 }}
/> />
<span style={{ fontWeight: 500 }}>
{plat.nom}
{(plat.investisseur_nom || plat.investisseur_prenom) && (
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6, fontWeight: 400 }}>
— {(() => { const n = plat.investisseur_nom ?? ''; const p = plat.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
</span>
)}
</span>
</label>
</td>
{MOIS_NUMS.map(m => (
<td key={m} style={cellStyle}>{fmtEUR(plat.mois[m] ?? null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(r(total))}</td>
</tr>
);
})}
{/* ── Séparateur ── */}
<tr><td colSpan={14} style={{ height: 4, background: 'var(--surface-2)' }} /></tr>
{/* ── Total brut mensuel ── */}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Total intérêt brut
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(t || null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 700 }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0)))}
</td>
</tr>
{/* ── Taux Flat Tax ── */}
<tr>
<td style={{ ...cellStyle, textAlign: 'left', color: 'var(--text-muted)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Taux total prélevé ({(TOTAL_TAX * 100).toFixed(1)} %)
</td>
{MOIS_NUMS.map((_, i) => (
<td key={i} style={{ ...cellStyle, color: 'var(--text-muted)' }}>
{totauxMois[i] > 0 ? `${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %` : '—'}
</td>
))}
<td style={{ ...cellStyle, color: 'var(--text-muted)' }}>{`${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %`}</td>
</tr>
{/* ── Total Flat Tax ── */}
<tr style={{ background: 'rgba(239,68,68,0.06)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--danger)', position: 'sticky', left: 0, background: 'rgba(239,68,68,0.06)' }}>
Montant total à payer (QR)
</td>
{totauxMois.map((t, i) => {
const { QR } = computeCases(t, rates);
return (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--danger)' : undefined }}>
{t > 0 ? fmtInt(QR) : <span style={{ color: 'var(--text-muted)' }}>-</span>}
</td>
);
})}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--danger)' }}>
{fmtInt(computeCases(r(totauxMois.reduce((a, b) => a + b, 0)), rates).QR)}
</td>
</tr>
{/* ── Intérêt net ── */}
<tr style={{ background: 'rgba(16,185,129,0.05)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--success)', position: 'sticky', left: 0, background: 'rgba(16,185,129,0.05)' }}>
Total intérêt net
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--success)' : undefined }}>
{fmtEUR(t > 0 ? r(t * (1 - TOTAL_TAX)) : null)}
</td>
))}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0) * (1 - TOTAL_TAX)))}
</td>
</tr>
</tbody>
</table>
<div style={{ padding: '10px 16px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<span>Taux {annee} : PFO {(rates.pfo*100).toFixed(1)} % + CSG {(rates.csg*100).toFixed(1)} % + CRDS {(rates.crds*100).toFixed(1)} % + Solidarité {(rates.solidarite*100).toFixed(1)} % = {(TOTAL_TAX*100).toFixed(1)} %</span>
<span>·</span>
<span>⏱ Déclaration et paiement dus dans les <strong>15 premiers jours du mois suivant</strong> l'encaissement</span>
<span>·</span>
<span>Base imposable : intérêts bruts <em>après</em> déduction de l'impôt prélevé à la source à l'étranger, <em>avant</em> déduction retenue "directive épargne"</span>
</div>
</div>
</>
)} )}
{/* ── Vue Report 2042 ── */} {/* ── Vue Report 2042 ── */}
@@ -421,12 +284,180 @@ export default function Cerfa2778Preview({ annee, activeView }) {
); );
} }
/* ── Suivi mensuel (matrice) — partagé entre 2778-SD et 2047 ── */
export function SuiviMensuelMatrix({ annee, plateformes, excluded, togglePlat, totauxMois, rates, TOTAL_TAX }) {
const { pfoAssujetti } = useUi();
const cellStyle = {
textAlign: 'right',
padding: '6px 10px',
fontSize: 'var(--fs-sm)',
whiteSpace: 'nowrap',
borderBottom: '1px solid var(--border)',
};
const headStyle = {
textAlign: 'center',
padding: '6px 10px',
fontSize: 'var(--fs-xs)',
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--surface-2)',
borderBottom: '1px solid var(--border)',
whiteSpace: 'nowrap',
};
return (
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Suivi mensuel des intérêts bruts</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} — plateformes étrangères</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr>
<th style={{ ...headStyle, textAlign: 'left', minWidth: 180, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Plateforme — Détenteur
</th>
{MOIS_LABELS.map((m, i) => (
<th key={m} style={headStyle}>{m}</th>
))}
<th style={{ ...headStyle, background: 'var(--surface-2)' }}>Total</th>
</tr>
</thead>
<tbody>
{plateformes.map(plat => {
const isExcluded = excluded.has(plat.id);
const total = Object.values(plat.mois).reduce((a, b) => a + b, 0);
return (
<tr key={plat.id} style={{ opacity: isExcluded ? 0.4 : 1, transition: 'opacity .15s' }}>
<td style={{
...cellStyle, textAlign: 'left',
position: 'sticky', left: 0,
background: 'var(--surface)',
fontWeight: 500,
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 'var(--fs-sm)', textTransform: 'none', letterSpacing: 'normal', color: 'var(--text)', fontWeight: 400, marginBottom: 0 }}>
<input
type="checkbox"
checked={!isExcluded}
onChange={() => togglePlat(plat.id)}
style={{ accentColor: 'var(--primary)', width: 14, height: 14, flexShrink: 0 }}
/>
<span style={{ fontWeight: 500 }}>
{plat.nom}
{(plat.investisseur_nom || plat.investisseur_prenom) && (
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6, fontWeight: 400 }}>
— {(() => { const n = plat.investisseur_nom ?? ''; const p = plat.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()}
</span>
)}
</span>
</label>
</td>
{MOIS_NUMS.map(m => (
<td key={m} style={cellStyle}>{fmtEUR(plat.mois[m] ?? null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(r(total))}</td>
</tr>
);
})}
{/* ── Séparateur ── */}
<tr><td colSpan={14} style={{ height: 4, background: 'var(--surface-2)' }} /></tr>
{/* ── Total brut mensuel ── */}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, position: 'sticky', left: 0, background: 'var(--surface-2)' }}>
Total intérêt brut
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600 }}>{fmtEUR(t || null)}</td>
))}
<td style={{ ...cellStyle, fontWeight: 700 }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0)))}
</td>
</tr>
{pfoAssujetti && (
<>
{/* ── Taux Flat Tax ── */}
<tr>
<td style={{ ...cellStyle, textAlign: 'left', color: 'var(--text-muted)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Taux total prélevé ({(TOTAL_TAX * 100).toFixed(1)} %)
</td>
{MOIS_NUMS.map((_, i) => (
<td key={i} style={{ ...cellStyle, color: 'var(--text-muted)' }}>
{totauxMois[i] > 0 ? `${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %` : '—'}
</td>
))}
<td style={{ ...cellStyle, color: 'var(--text-muted)' }}>{`${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %`}</td>
</tr>
{/* ── Total Flat Tax ── */}
<tr style={{ background: 'rgba(239,68,68,0.06)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--danger)', position: 'sticky', left: 0, background: 'rgba(239,68,68,0.06)' }}>
Montant total à payer (QR)
</td>
{totauxMois.map((t, i) => {
const { QR } = computeCases(t, rates);
return (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--danger)' : undefined }}>
{t > 0 ? fmtInt(QR) : <span style={{ color: 'var(--text-muted)' }}>-</span>}
</td>
);
})}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--danger)' }}>
{fmtInt(computeCases(r(totauxMois.reduce((a, b) => a + b, 0)), rates).QR)}
</td>
</tr>
{/* ── Intérêt net ── */}
<tr style={{ background: 'rgba(16,185,129,0.05)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 600, color: 'var(--success)', position: 'sticky', left: 0, background: 'rgba(16,185,129,0.05)' }}>
Total intérêt net
</td>
{totauxMois.map((t, i) => (
<td key={i} style={{ ...cellStyle, fontWeight: 600, color: t > 0 ? 'var(--success)' : undefined }}>
{fmtEUR(t > 0 ? r(t * (1 - TOTAL_TAX)) : null)}
</td>
))}
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>
{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0) * (1 - TOTAL_TAX)))}
</td>
</tr>
</>
)}
</tbody>
</table>
<div style={{ padding: '10px 16px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', borderTop: '1px solid var(--border)', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
{pfoAssujetti && (
<>
<span>Taux {annee} : PFO {(rates.pfo*100).toFixed(1)} % + CSG {(rates.csg*100).toFixed(1)} % + CRDS {(rates.crds*100).toFixed(1)} % + Solidarité {(rates.solidarite*100).toFixed(1)} % = {(TOTAL_TAX*100).toFixed(1)} %</span>
<span>·</span>
<span>⏱ Déclaration et paiement dus dans les <strong>15 premiers jours du mois suivant</strong> l'encaissement</span>
<span>·</span>
</>
)}
<span>Base imposable : intérêts bruts <em>après</em> déduction de l'impôt prélevé à la source à l'étranger, <em>avant</em> déduction retenue "directive épargne"</span>
</div>
</div>
);
}
/* ── Report annuel 2042 ── */ /* ── Report annuel 2042 ── */
function Report2042Block({ annee, totauxMois, rates, plateformes, excluded }) { export function Report2042Block({ annee, totauxMois, rates, plateformes, excluded, source = '2778' }) {
const totalBA = r(totauxMois.reduce((a, b) => a + b, 0)); const totalBA = r(totauxMois.reduce((a, b) => a + b, 0));
if (totalBA === 0) return null; if (totalBA === 0) return null;
const totalIA = computeCases(totalBA, rates).IA; const totalIA = computeCases(totalBA, rates).IA;
const { pfoAssujetti } = useUi();
const isFrom2047 = source === '2047';
const noteBA = isFrom2047
? "Intérêts bruts totaux encaissés sur l'année (cases 233 + 250 des Données 2047)"
: "Intérêts bruts totaux encaissés sur l'année (somme des cases BA)";
const noteIA = isFrom2047
? "Correspond à la case 273 des Données 2047 — s'impute sur l'IR définitif, l'excédent est restitué"
: "Somme des cases IA de vos 2778-SD — s'impute sur l'IR définitif, l'excédent est restitué";
// Détail par plateforme incluse (annuel) // Détail par plateforme incluse (annuel)
const platDetail = plateformes const platDetail = plateformes
@@ -482,10 +513,12 @@ function Report2042Block({ annee, totauxMois, rates, plateformes, excluded }) {
<Case2042 <Case2042
code="2TR" code="2TR"
label="Produits de placement à revenu fixe de source étrangère" label="Produits de placement à revenu fixe de source étrangère"
note="Intérêts bruts totaux encaissés sur l'année (somme des cases BA)" note={noteBA}
value={Math.round(totalBA)} value={Math.round(totalBA)}
breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))} breakdown={platDetail.map(p => ({ nom: p.nom, val: Math.round(p.ba) }))}
/> />
{pfoAssujetti && (
<>
<Case2042 <Case2042
code="2BH" code="2BH"
label="Produits soumis aux prélèvements sociaux (idem 2TR)" label="Produits soumis aux prélèvements sociaux (idem 2TR)"
@@ -496,23 +529,44 @@ function Report2042Block({ annee, totauxMois, rates, plateformes, excluded }) {
<Case2042 <Case2042
code="2CK" code="2CK"
label="Prélèvement forfaitaire non libératoire déjà versé (acompte)" label="Prélèvement forfaitaire non libératoire déjà versé (acompte)"
note="Somme des cases IA de vos 2778-SD — s'impute sur l'IR définitif, l'excédent est restitué" note={noteIA}
value={totalIA} value={totalIA}
breakdown={platDetail.map(p => ({ nom: p.nom, val: p.ia }))} breakdown={platDetail.map(p => ({ nom: p.nom, val: p.ia }))}
/> />
</>
)}
</div> </div>
{pfoAssujetti ? (
<div style={{ <div style={{
padding: '10px 14px', borderRadius: 8, padding: '10px 14px', borderRadius: 8,
background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', 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)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)',
}}> }}>
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFO ?</strong> <strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Comment fonctionne le PFO ?</strong>
Le prélèvement forfaitaire obligatoire (PFO, case 2CK) versé via la 2778-SD est un <em>acompte</em> sur l'impôt sur le revenu, non libératoire. {isFrom2047 ? (
<>Le prélèvement forfaitaire obligatoire (PFO, case 2CK) correspond à la case <strong>273</strong> des Données 2047 : c'est un <em>acompte</em> sur l'impôt sur le revenu, non libératoire.</>
) : (
<>Le prélèvement forfaitaire obligatoire (PFO, case 2CK) versé via la 2778-SD est un <em>acompte</em> sur l'impôt sur le revenu, non libératoire.</>
)}{' '}
L'imposition définitive est calculée lors de votre 2042 : par défaut au taux de <strong>{(rates.pfo * 100).toFixed(1)} %</strong> (Flat Tax), L'imposition définitive est calculée lors de votre 2042 : par défaut au taux de <strong>{(rates.pfo * 100).toFixed(1)} %</strong> (Flat Tax),
ou sur option expresse au barème progressif. L'acompte déjà versé (2CK) s'impute sur l'IR définitif — tout excédent vous est restitué. ou sur option expresse au barème progressif. L'acompte déjà versé (2CK) s'impute sur l'IR définitif — tout excédent vous est restitué.{' '}
Le montant 2BH est déclaré en sus de 2TR uniquement pour neutraliser les prélèvements sociaux déjà prélevés via la 2778-SD. {isFrom2047 ? (
<>Le montant 2BH (case <strong>272</strong> des Données 2047) est déclaré en sus de 2TR uniquement pour neutraliser les prélèvements sociaux déjà prélevés.</>
) : (
<>Le montant 2BH est déclaré en sus de 2TR uniquement pour neutraliser les prélèvements sociaux déjà prélevés via la 2778-SD.</>
)}
</div> </div>
) : (
<div style={{
padding: '10px 14px', borderRadius: 8,
background: 'var(--surface-2)',
fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)',
}}>
Les cases 2BH et 2CK ne s'appliquent que si l'option de déclaration 2778-SD est activée
(Paramétrage&nbsp;&gt;&nbsp;Ma fiscalité).
</div>
)}
</div> </div>
); );
} }
+166
View File
@@ -343,6 +343,172 @@ export default function Aide() {
</p> </p>
</FaqItem> </FaqItem>
<FaqSectionTitle>Fiscalité</FaqSectionTitle>
<FaqItem question="Comment la fiscalité fonctionne-t-elle dans Crowdlending ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
La page <strong style={{ color: 'var(--text)' }}>Fiscalité</strong> (menu de gauche) reprend, année par année,
les montants nécessaires pour préparer votre déclaration de revenus. Elle est organisée en quatre onglets,
chacun correspondant à un formulaire CERFA officiel :
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2561</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}><strong style={{ color: 'var(--text)' }}>IFU — Imprimé fiscal unique.</strong> Récapitulatif annuel transmis par les plateformes françaises soumises au prélèvement forfaitaire unique (PFU).</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2047</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}><strong style={{ color: 'var(--text)' }}>Revenus encaissés à l'étranger.</strong> Visible 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é.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2778-SD</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}><strong style={{ color: 'var(--text)' }}>Prélèvement forfaitaire obligatoire (PFO)</strong>, selon votre situation fiscale — déclaration mensuelle de l'acompte dû sur les intérêts de plateformes étrangères (voir la question suivante).</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2042</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}><strong style={{ color: 'var(--text)' }}>Déclaration des revenus.</strong> La déclaration principale : reprend les cases calculées à partir des trois formulaires ci-dessus (2TT, 2TR, 2BH, 2CK, 2TY).</td>
</tr>
</tbody>
</table>
<p>Le sens de circulation des données est toujours le même :</p>
<ul style={{ margin: '8px 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2561</code> et <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2047</code> alimentent directement la <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2042</code>.</li>
<li>La <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2778-SD</code> (mensuelle) alimente la <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2047</code> (annuelle), qui seule est ensuite reportée sur la 2042 — la 2778-SD n'est qu'un mécanisme d'acompte intermédiaire, jamais une déclaration de substitution à la 2047.</li>
</ul>
<p>
Un pavé « À quoi correspond chaque CERFA ? », avec un schéma récapitulatif, est affiché directement en haut de
l'onglet 2047 lorsque vous avez des plateformes étrangères.
</p>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: 'var(--text)' }}>Important</strong> — 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 sur le site des impôts :
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>
</FaqItem>
<FaqItem question="Comment savoir si je suis concerné par la déclaration mensuelle sur mes plateformes étrangères ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Cette question concerne le formulaire <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2778-SD</code> (prélèvement
forfaitaire obligatoire, ou PFO). Elle ne se pose que si vous détenez au moins une plateforme domiciliée
hors de France.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>La règle</h4>
<p>
Le PFO s'applique aux personnes physiques fiscalement domiciliées en France dont le <strong style={{ color: 'var(--text)' }}>revenu
fiscal de référence (RFR)</strong> de l'avant-dernière année est égal ou supérieur à :
</p>
<div style={{ display: 'flex', gap: 12, margin: '8px 0 12px', flexWrap: 'wrap' }}>
<div style={{ padding: '6px 14px', borderRadius: 20, background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
25 000 € — célibataire, divorcé ou veuf
</div>
<div style={{ padding: '6px 14px', borderRadius: 20, background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
50 000 € — couple marié ou pacsé
</div>
</div>
<p>
Votre RFR figure sur votre <strong style={{ color: 'var(--text)' }}>avis d'imposition</strong> de l'année précédente
(ligne « Revenu fiscal de référence »). En dessous de ces seuils, vous êtes dispensé du PFO — mais les
prélèvements sociaux restent dus.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Où l'activer dans l'application</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Allez dans <strong style={{ color: 'var(--text)' }}>Paramétrage → Ma fiscalité</strong>.</li>
<li>Dépliez la carte <strong style={{ color: 'var(--text)' }}>« CERFA 2778-SD — Prélèvement Forfaitaire Obligatoire (PFO) pour des revenus de source étrangère »</strong> pour revoir le détail des seuils.</li>
<li>Activez le bouton si votre situation vous rend redevable du PFO.</li>
</ol>
<p>
Ce réglage est global : une fois activé, l'onglet <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2778-SD</code> apparaît
dans la page Fiscalité, les cases <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2BH</code> / <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2CK</code> se
remplissent dans le report 2042, et les lignes de suivi mensuel du PFO (taux, montant à payer, intérêt net)
s'affichent dans les tableaux « Suivi mensuel » des onglets 2047 et 2778-SD. Si vous le désactivez, ces
éléments sont masqués car ils ne s'appliquent pas à votre situation.
</p>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: 'var(--text)' }}>À retenir</strong> — que le PFO s'applique ou non à vous, la déclaration
annuelle via le CERFA 2047 reste <strong style={{ color: 'var(--text)' }}>obligatoire</strong> dès que vous avez
perçu des intérêts d'une plateforme étrangère. Ce paramètre reflète votre propre situation fiscale : en cas
de doute, vérifiez votre avis d'imposition ou consultez un professionnel.
</p>
</FaqItem>
<FaqItem question="Comment est calculé le crédit d'impôt sur les intérêts perçus d'une plateforme étrangère ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Dans l'onglet <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2047</code>, vue
« Données 2047 », chaque plateforme étrangère est classée automatiquement selon la convention fiscale
signée entre la France et son pays de domiciliation.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Éligible au crédit d'impôt (cases 233 à 238)</h4>
<p>
Le pays a une convention fiscale <strong style={{ color: 'var(--text)' }}>active</strong> et n'impose pas
l'imposition exclusivement dans le pays de résidence. Le crédit d'impôt (case 237) est égal à l'impôt
réellement prélevé à l'étranger (case 236), plafonné au taux conventionnel du pays lorsque celui-ci est
chiffré (case 234) — ou <strong style={{ color: 'var(--text)' }}>non plafonné</strong> pour certains pays dont
la convention prévoit un crédit égal à l'impôt prélevé sans limite de taux.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Non éligible (case 250)</h4>
<p>
Le pays est absent des conventions fiscales référencées, sa convention est suspendue ou caduque, ou elle
prévoit une imposition exclusivement dans le pays de résidence. Dans ce cas, aucun crédit d'impôt n'est
calculé.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Sous-totaux par pays</h4>
<p style={{ marginBottom: 0 }}>
Le formulaire papier 2047 se remplit <strong style={{ color: 'var(--text)' }}>par pays</strong>, pas par
plateforme : si plusieurs de vos plateformes sont domiciliées dans le même pays, une ligne « Sous-total »
apparaît automatiquement sous le groupe pour faciliter le report. Les totaux 8VL et 8PL (report 2042)
ainsi que les cases 251/252 cumulent l'ensemble de vos plateformes étrangères, arrondis à l'euro comme
l'exige le formulaire officiel.
</p>
</FaqItem>
<FaqItem question="Comment utiliser les onglets de la page Fiscalité pour remplir ma déclaration ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Chaque onglet CERFA propose généralement plusieurs vues : un <strong style={{ color: 'var(--text)' }}>suivi
mensuel</strong> (le détail plateforme par plateforme, mois par mois), une vue <strong style={{ color: 'var(--text)' }}>« Données »</strong> (les
cases du formulaire lui-même), et une vue <strong style={{ color: 'var(--text)' }}>« Report 2042 »</strong> (ce
qui doit être reporté sur la déclaration principale).
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Ordre suggéré</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Sélectionnez l'<strong style={{ color: 'var(--text)' }}>année</strong> concernée en haut de la page.</li>
<li>Onglet <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2561</code> si vous avez des plateformes françaises soumises au PFU.</li>
<li>Onglets <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2778-SD</code> puis <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2047</code> si vous avez des plateformes étrangères — le suivi mensuel du 2778-SD documente le détail, la vue « Données 2047 » regroupe l'annuel par pays.</li>
<li>Onglet <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>2042</code> en dernier : sa vue « Données 2042 » et sa synthèse fiscale récapitulent toutes les cases (2TT, 2TR, 2BH, 2CK, 2TY) à saisir sur le site des impôts.</li>
</ol>
<p>
Les montants affichés dans les vues « Données » et les cases officielles sont arrondis à l'euro, comme
l'exigent les formulaires CERFA (les montants intermédiaires du suivi mensuel restent au centime près,
pour la traçabilité).
</p>
<p style={{ marginBottom: 0 }}>
Comme rappelé sur la page elle-même : ce module est une <strong style={{ color: 'var(--text)' }}>aide à la
préparation</strong> de votre déclaration, il ne s'y substitue pas. Contrôlez chaque case avant de la
reporter — vous restez seul responsable de l'exactitude de votre déclaration.
</p>
</FaqItem>
<FaqSectionTitle>API et Serveur MCP pour l'IA</FaqSectionTitle> <FaqSectionTitle>API et Serveur MCP pour l'IA</FaqSectionTitle>
<FaqItem question="Comment configurer le serveur MCP pour l'utiliser dans Claude Desktop" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}> <FaqItem question="Comment configurer le serveur MCP pour l'utiliser dans Claude Desktop" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
+1 -1
View File
@@ -313,7 +313,7 @@ export default function Plateformes() {
setSearchParams(p => { const n = new URLSearchParams(p); n.set('plat', nom); return n; }, { replace: true }); setSearchParams(p => { const n = new URLSearchParams(p); n.set('plat', nom); return n; }, { replace: true });
}; };
const [selectedDetenteurId, setSelectedDetenteurId] = useState(() => { try { return localStorage.getItem('cl_plat_detenteur') || 'all'; } catch { return 'all'; } }); const [selectedDetenteurId, setSelectedDetenteurId] = useState(() => { try { return localStorage.getItem('cl_plat_detenteur') || 'all'; } catch { return 'all'; } });
const [selectedYear, setSelectedYear] = useState(() => { try { return localStorage.getItem('cl_plat_year') || String(new Date().getFullYear()); } catch { return String(new Date().getFullYear()); } }); const [selectedYear, setSelectedYear] = useState(''); // toujours "Depuis le début" au démarrage de la page
const [windowStart, setWindowStart] = useState(0); const [windowStart, setWindowStart] = useState(0);
const [drDetaille, setDrDetaille] = useState(() => { const [drDetaille, setDrDetaille] = useState(() => {
try { return localStorage.getItem('cl_dr_plat_detaille') === 'true'; } catch { return false; } try { return localStorage.getItem('cl_dr_plat_detaille') === 'true'; } catch { return false; }
+147 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageIcon from '../components/PageIcon.jsx'; import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx'; import EmptyState from '../components/EmptyState.jsx';
import Pagination from '../components/Pagination.jsx'; import Pagination from '../components/Pagination.jsx';
@@ -10,6 +11,7 @@ import { fmtEUR, fmtStatut } from '../utils/format.js';
import Cerfa2561Preview from '../components/Cerfa2561Preview.jsx'; import Cerfa2561Preview from '../components/Cerfa2561Preview.jsx';
import CerfaRecapTable from '../components/CerfaRecapTable.jsx'; import CerfaRecapTable from '../components/CerfaRecapTable.jsx';
import Cerfa2778Preview from '../components/Cerfa2778Preview.jsx'; import Cerfa2778Preview from '../components/Cerfa2778Preview.jsx';
import Cerfa2047Preview from '../components/Cerfa2047Preview.jsx';
import Cerfa2042Preview from '../components/Cerfa2042Preview.jsx'; import Cerfa2042Preview from '../components/Cerfa2042Preview.jsx';
/* ── YearSelector ────────────────────────────────────────────── */ /* ── YearSelector ────────────────────────────────────────────── */
@@ -144,8 +146,78 @@ function ExportDropdown({ disabled, onCSV, onJSON }) {
); );
} }
/* ── 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 ─────────────────────────────────────── */ /* ── Composant principal ─────────────────────────────────────── */
export default function TaxReport() { export default function TaxReport() {
const navigate = useNavigate();
const { activeId, activeView } = useInvestisseur(); const { activeId, activeView } = useInvestisseur();
const { pfoAssujetti } = useUi(); const { pfoAssujetti } = useUi();
const [annee, setAnnee] = useState(String(new Date().getFullYear())); const [annee, setAnnee] = useState(String(new Date().getFullYear()));
@@ -157,10 +229,14 @@ export default function TaxReport() {
const [detailExpanded, setDetailExpanded] = useState(false); const [detailExpanded, setDetailExpanded] = useState(false);
const [cerfaExpanded, setCerfaExpanded] = useState(false); const [cerfaExpanded, setCerfaExpanded] = useState(false);
const [hasPlateformes, setHasPlateformes] = useState(true); // true par défaut pour éviter un flash du mauvais message 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 configurée (pour l'onglet CERFA 2047 — indépendant de l'année sélectionnée, même principe que le 2778-SD)
/* ── Plateformes configurées ? (pour distinguer "pas de plateforme" de "pas encore de données") ── */ /* ── Plateformes configurées ? (pour distinguer "pas de plateforme" de "pas encore de données") ── */
useEffect(() => { useEffect(() => {
api.get('/plateformes').then(p => setHasPlateformes((p || []).length > 0)).catch(() => {}); api.get('/plateformes').then(p => {
setHasPlateformes((p || []).length > 0);
setHasEtranger((p || []).some(pl => pl.domiciliation && pl.domiciliation !== 'FR'));
}).catch(() => {});
}, []); }, []);
/* ── Chargement des années disponibles ── */ /* ── Chargement des années disponibles ── */
@@ -196,14 +272,14 @@ export default function TaxReport() {
setData2042({ setData2042({
case_2TT: frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0), 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_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) + 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) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo), 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: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0), case_2TY: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
}); });
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}; };
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line useEffect(load, [activeId, activeView, annee, pfoAssujetti]); // eslint-disable-line
/* ── Pagination détail ── */ /* ── Pagination détail ── */
const detail = data?.detail ?? []; const detail = data?.detail ?? [];
@@ -301,10 +377,73 @@ export default function TaxReport() {
</p> </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."
/>
)}
</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 && ( {!detailExpanded && !cerfaExpanded && (
<div className="dr-tabs"> <div className="dr-tabs">
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button> <button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
<button className={`dr-tab${activeTab === 'cerfa' ? ' active' : ''}`} onClick={() => setActiveTab('cerfa')}>CERFA 2561 (IFU)</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>} {pfoAssujetti && <button className={`dr-tab${activeTab === '2778' ? ' active' : ''}`} onClick={() => setActiveTab('2778')}>CERFA 2778-SD</button>}
</div> </div>
)} )}
@@ -324,6 +463,10 @@ export default function TaxReport() {
/> />
)} )}
{activeTab === '2047' && hasEtranger && !detailExpanded && !cerfaExpanded && (
<Cerfa2047Preview annee={annee} activeView={activeView} />
)}
{activeTab === '2778' && pfoAssujetti && !detailExpanded && !cerfaExpanded && ( {activeTab === '2778' && pfoAssujetti && !detailExpanded && !cerfaExpanded && (
<Cerfa2778Preview annee={annee} activeView={activeView} /> <Cerfa2778Preview annee={annee} activeView={activeView} />
)} )}