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
+1206 -189

No files matched your search

+169
View File
@@ -623,4 +623,173 @@ router.get('/2778', (req, res) => {
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;