Files
crowdlending-app/backend/src/routes/taxreport.js
T

988 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express';
import db from '../db/index.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
const router = Router();
router.use(requireInvestisseur);
const round2 = v => Math.round((v ?? 0) * 100) / 100;
// Échappement CSV (séparateur ';') — remplace les caractères qui casseraient les colonnes.
// Bug préexistant : cette fonction était utilisée plus bas dans /export sans être définie
// (ReferenceError à l'exécution), corrigé au passage.
const esc = v => String(v ?? '').replace(/;/g, ',').replace(/[\r\n]+/g, ' ');
router.get('/', (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 recap = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts),0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux),0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire,
COALESCE(SUM(r.net_recu),0) AS net_recu,
COUNT(*) AS nb_remboursements
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
`).get(invArg, annee);
// Intérêts plateforme (type=interets_plateforme, pas d'investissement — rattachés via
// bonus_plateforme_id/bonus_investisseur_id) : mêmes règles fiscales que la plateforme
// associée, donc à additionner aux totaux comme des intérêts d'investissement classiques.
const invCondBonus = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const recapInteretsPlateforme = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts),0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux),0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire,
COALESCE(SUM(r.net_recu),0) AS net_recu,
COUNT(*) AS nb_remboursements
FROM remboursements r
WHERE ${invCondBonus}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
`).get(invArg, annee);
recap.interets_bruts += recapInteretsPlateforme.interets_bruts;
recap.prelev_sociaux += recapInteretsPlateforme.prelev_sociaux;
recap.prelev_forfaitaire += recapInteretsPlateforme.prelev_forfaitaire;
recap.net_recu += recapInteretsPlateforme.net_recu;
recap.nb_remboursements += recapInteretsPlateforme.nb_remboursements;
// Pertes en capital : uniquement les dossiers explicitement déclarés en perte
// définitive (statut='perte_definitive'), avec le montant réellement acté dans
// investissement_pertes — pas les simples retards, ni les clôtures normales.
const pertes = db.prepare(`
SELECT i.id, i.nom_projet, p.nom AS plateforme_nom,
i.montant_investi,
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) AS capital_rembourse,
ip.montant_perte AS perte_capital,
i.statut, ip.date_effet
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investissement_pertes ip ON ip.investissement_id = i.id
WHERE ${invCond}
AND i.statut = 'perte_definitive'
AND substr(ip.date_effet,1,4) = ?
`).all(invArg, annee);
const pertesTotales = pertes.reduce((s, p) => s + Math.max(0, p.perte_capital), 0);
const detail = db.prepare(`
SELECT i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
SUM(r.interets_bruts) AS interets_bruts,
SUM(r.prelev_sociaux) AS prelev_sociaux,
SUM(r.prelev_forfaitaire) AS prelev_forfaitaire,
SUM(r.net_recu) AS net_recu,
COUNT(r.id) AS nb_echeances
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
GROUP BY i.id
ORDER BY interets_bruts DESC
`).all(invArg, annee);
const corrCond = scopeAll
? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'c.investisseur_id = ?';
const corrections = db.prepare(`
SELECT c.id, c.date, c.montant, c.notes,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom
FROM corrections_solde c
JOIN plateformes p ON p.id = c.plateforme_id
JOIN investisseurs inv ON inv.id = c.investisseur_id
WHERE ${corrCond}
AND substr(c.date,1,4) = ?
ORDER BY c.date DESC
`).all(invArg, annee);
const totalCorrections = corrections.reduce((s, c) => s + c.montant, 0);
const interetsNets = recap.interets_bruts - recap.prelev_sociaux - recap.prelev_forfaitaire;
// Au moins une plateforme PSAN (crypto) configurée, quelle que soit sa domiciliation — pour
// exposer cerfa2042cpro (case 5HQ, micro-BNC) au frontend. Même logique que v1/taxreport.js
// (Olivier 30/08/26) : ce handler interne '/' alimente le state `data` de TaxReport.jsx, donc
// il doit porter ce champ lui aussi (l'agrégat /api/v1/taxreport est un endpoint distinct).
const hasPsan = !!db.prepare(
`SELECT 1 FROM plateformes WHERE user_id = ? AND categorie_plateforme = 'psan' LIMIT 1`
).get(req.user.id);
const cerfa2042cpro = hasPsan ? build2042CPro(annee, invCond, invCondBonus, invArg) : null;
const recapTotal = {
interets_bruts: round2(recap.interets_bruts),
prelev_sociaux: round2(recap.prelev_sociaux),
prelev_forfaitaire: round2(recap.prelev_forfaitaire),
interets_nets: round2(interetsNets),
interets_nets_corriges: round2(interetsNets + totalCorrections),
nb_remboursements: recap.nb_remboursements,
total_corrections: round2(totalCorrections),
};
const cases = {
case_2TR: round2(recap.interets_bruts),
case_2CK: round2(recap.prelev_forfaitaire),
case_2BH: round2(recap.interets_bruts),
case_2BH_perte_capital: round2(pertesTotales),
note: "Les cases sont indicatives. Verifiez avec votre situation fiscale reelle.",
};
res.json({ annee, recap: recapTotal, pertes, pertesTotales: round2(pertesTotales), detail, corrections, cases, has_psan: hasPsan, cerfa2042cpro });
});
router.get('/export', (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 i.nom_projet, p.nom AS plateforme,
r.date_remb, r.capital, r.interets_bruts,
r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu, r.statut
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond} AND substr(r.date_remb,1,4) = ?
ORDER BY r.date_remb
`).all(invArg, annee);
const invCondBonusExp = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const interetsPlateformeRows = db.prepare(`
SELECT
'— Intérêts plateforme' || CASE WHEN NULLIF(r.notes, '') IS NOT NULL THEN ' — ' || r.notes ELSE '' END AS nom_projet,
p.nom AS plateforme,
r.date_remb, r.capital, r.interets_bruts,
r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu, r.statut
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invCondBonusExp} AND substr(r.date_remb,1,4) = ? AND r.type = 'interets_plateforme'
ORDER BY r.date_remb
`).all(invArg, annee);
const corrCond2 = scopeAll
? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'c.investisseur_id = ?';
const corrRows = db.prepare(`
SELECT c.notes AS nom_projet, p.nom AS plateforme, c.date AS date_remb,
0 AS capital, 0 AS interets_bruts,
0 AS prelev_sociaux, 0 AS prelev_forfaitaire,
c.montant AS net_recu, 'correction_solde' AS statut
FROM corrections_solde c
JOIN plateformes p ON p.id = c.plateforme_id
WHERE ${corrCond2} AND substr(c.date,1,4) = ?
ORDER BY c.date
`).all(invArg, annee);
const header = ['Projet','Plateforme','Date','Capital','Interets bruts','Prelev sociaux','Impot revenu','Net recu','Statut'];
const allRows = [...rows, ...interetsPlateformeRows, ...corrRows].sort((a, b) => (a.date_remb || '').localeCompare(b.date_remb || ''));
const csv = [
header.join(';'),
...allRows.map(r => [
esc(r.nom_projet), esc(r.plateforme), r.date_remb,
r.capital, r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
r.net_recu, r.statut,
].join(';')),
].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="2778-SD-${annee}.csv"`);
res.send('\uFEFF' + csv);
});
function buildCerfa2561(annee, invCond, invCondBonus2561, invArg) {
const rows = db.prepare(`
SELECT
p.id AS plateforme_id,
p.nom AS plateforme_nom,
p.domiciliation,
p.fiscalite,
p.type_produit_fiscal,
inv.id AS investisseur_id,
inv.nom AS investisseur_nom,
inv.prenom AS investisseur_prenom,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
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'
GROUP BY p.id, inv.id
ORDER BY p.nom, inv.nom
`).all(invArg, annee);
// Intérêts plateforme (pas d'investissement — rattachés via bonus_plateforme_id/
// bonus_investisseur_id) : fusionnés sur la même ligne plateforme × investisseur que
// les intérêts d'investissement classiques, car la fiscalité (domiciliation/fiscalite/
// type_produit_fiscal) dépend de la plateforme, pas de l'investissement précis.
const rowsInteretsPlateforme = db.prepare(`
SELECT
p.id AS plateforme_id,
p.nom AS plateforme_nom,
p.domiciliation,
p.fiscalite,
p.type_produit_fiscal,
inv.id AS investisseur_id,
inv.nom AS investisseur_nom,
inv.prenom AS investisseur_prenom,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
WHERE ${invCondBonus2561}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
GROUP BY p.id, inv.id
`).all(invArg, annee);
// Fusion des deux sources sur la clé plateforme × investisseur
const rowsMap = {};
for (const r of rows) rowsMap[`${r.plateforme_id}_${r.investisseur_id}`] = { ...r };
for (const r of rowsInteretsPlateforme) {
const key = `${r.plateforme_id}_${r.investisseur_id}`;
if (rowsMap[key]) {
rowsMap[key].interets_bruts += r.interets_bruts;
rowsMap[key].prelev_sociaux += r.prelev_sociaux;
rowsMap[key].prelev_forfaitaire += r.prelev_forfaitaire;
} else {
rowsMap[key] = { ...r };
}
}
const rowsMerged = Object.values(rowsMap).sort((a, b) =>
a.plateforme_nom.localeCompare(b.plateforme_nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
// Pertes en capital par plateforme × investisseur — uniquement les dossiers
// explicitement déclarés en perte définitive (voir note plus haut dans la route '/').
const pertesRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
COALESCE(SUM(ip.montant_perte), 0) AS perte_capital
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investissement_pertes ip ON ip.investissement_id = i.id
WHERE ${invCond}
AND i.statut = 'perte_definitive'
AND substr(ip.date_effet,1,4) = ?
GROUP BY p.id, i.investisseur_id
`).all(invArg, annee);
const pertesMap = {};
for (const p of pertesRows) {
pertesMap[`${p.plateforme_id}_${p.investisseur_id}`] = Math.max(0, p.perte_capital);
}
const lignes = rowsMerged.map(r => {
const key = `${r.plateforme_id}_${r.investisseur_id}`;
const perteCapital = pertesMap[key] ?? 0;
const isFlatTax = r.domiciliation === 'FR' && r.fiscalite === 'flat_tax';
const use2TR = r.type_produit_fiscal === '2TR';
const interetsNets = round2(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire);
return {
annee,
plateforme_id: r.plateforme_id,
plateforme_nom: r.plateforme_nom,
domiciliation: r.domiciliation,
fiscalite: r.fiscalite,
type_produit_fiscal: r.type_produit_fiscal,
investisseur_id: r.investisseur_id,
investisseur_nom: r.investisseur_nom,
investisseur_prenom: r.investisseur_prenom,
interets_bruts: round2(r.interets_bruts),
prelev_sociaux: round2(r.prelev_sociaux),
prelev_forfaitaire: round2(r.prelev_forfaitaire),
interets_nets: interetsNets,
// Cases fiscales — d'après IFU réels :
// 2TT ou 2TR = intérêts bruts (toutes plateformes)
// 2BH = intérêts bruts si PS déjà prélevés (prelev_sociaux > 0)
// 2CK = PFNL déjà versé (flat-tax FR uniquement)
// 2TY = pertes en capital
case_2TT: !use2TR ? Math.round(r.interets_bruts) : 0,
case_2TR: use2TR ? Math.round(r.interets_bruts) : 0,
case_2BH: r.prelev_sociaux > 0 ? Math.round(r.interets_bruts) : 0,
case_2CK: isFlatTax ? Math.round(r.prelev_forfaitaire) : 0,
case_2TY: perteCapital > 0 ? Math.round(perteCapital) : 0,
};
});
// Breakdown mensuel par plateforme × investisseur
const moisRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
GROUP BY p.id, i.investisseur_id, mois
`).all(invArg, annee);
const moisRowsInteretsPlateforme = db.prepare(`
SELECT
p.id AS plateforme_id,
r.bonus_investisseur_id AS investisseur_id,
substr(r.date_remb,6,2) AS mois,
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux,
COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invCondBonus2561}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
GROUP BY p.id, r.bonus_investisseur_id, mois
`).all(invArg, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = {
interets_bruts: round2(m.interets_bruts),
prelev_sociaux: round2(m.prelev_sociaux),
prelev_forfaitaire: round2(m.prelev_forfaitaire),
};
}
for (const m of moisRowsInteretsPlateforme) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
const existing = moisMap[key][m.mois];
moisMap[key][m.mois] = {
interets_bruts: round2((existing?.interets_bruts ?? 0) + m.interets_bruts),
prelev_sociaux: round2((existing?.prelev_sociaux ?? 0) + m.prelev_sociaux),
prelev_forfaitaire: round2((existing?.prelev_forfaitaire ?? 0) + m.prelev_forfaitaire),
};
}
const lignesWithMois = lignes.map(l => ({
...l,
mois: moisMap[`${l.plateforme_id}_${l.investisseur_id}`] ?? {},
}));
return { annee, lignes: lignesWithMois };
}
/* ── CERFA 2561 — synthèse par plateforme × investisseur ── */
router.get('/cerfa2561', (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 invCondBonus2561 = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
res.json(buildCerfa2561(annee, invCond, invCondBonus2561, invArg));
});
/* ── CERFA 2561 — détail des remboursements par plateforme × investisseur ── */
router.get('/cerfa2561/remboursements', (req, res) => {
const { annee, plateforme_id, investisseur_id } = req.query;
if (!annee || !plateforme_id) return res.status(400).json({ error: 'annee et plateforme_id requis' });
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 platCond = investisseur_id
? 'AND p.id = ? AND i.investisseur_id = ?'
: 'AND p.id = ?';
const platArgs = investisseur_id
? [Number(plateforme_id), Number(investisseur_id)]
: [Number(plateforme_id)];
const rows = db.prepare(`
SELECT
r.id, r.date_remb, r.capital, r.interets_bruts,
r.prelev_sociaux, r.prelev_forfaitaire, r.interets_nets, r.net_recu,
r.statut, r.notes,
i.nom_projet
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond} ${platCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
ORDER BY r.date_remb
`).all(invArg, ...platArgs, annee);
// Intérêts plateforme correspondant à la même plateforme (et investisseur si précisé)
const invCondBonusDetail = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const platCondBonus = investisseur_id
? 'AND p.id = ? AND r.bonus_investisseur_id = ?'
: 'AND p.id = ?';
const rowsInteretsPlateforme = db.prepare(`
SELECT
r.id, r.date_remb, r.capital, r.interets_bruts,
r.prelev_sociaux, r.prelev_forfaitaire, r.interets_nets, r.net_recu,
r.statut, r.notes,
'— Intérêts plateforme' || CASE WHEN NULLIF(r.notes, '') IS NOT NULL THEN ' — ' || r.notes ELSE '' END AS nom_projet
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invCondBonusDetail} ${platCondBonus}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
ORDER BY r.date_remb
`).all(invArg, ...platArgs, annee);
const allRows = [...rows, ...rowsInteretsPlateforme].sort((a, b) => (a.date_remb || '').localeCompare(b.date_remb || ''));
res.json(allRows);
});
/* ── Années disponibles ── */
router.get('/years', (req, res) => {
const scopeAll = req.query.scope === 'all';
let invWhere, invParams;
if (scopeAll) {
invWhere = 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [req.user.id];
} else {
const invId = Number(req.header('X-Investisseur-Id'));
if (!invId) return res.status(400).json({ error: 'Missing investisseur id' });
invWhere = 'i.investisseur_id = ?';
invParams = [invId];
}
const rows = db.prepare(`
SELECT DISTINCT strftime('%Y', r.date_remb) AS annee
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${invWhere}
AND r.type = 'normal'
ORDER BY annee DESC
`).all(...invParams);
const invWhereBonus = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const rowsInteretsPlateforme = db.prepare(`
SELECT DISTINCT strftime('%Y', r.date_remb) AS annee
FROM remboursements r
WHERE ${invWhereBonus}
AND r.type = 'interets_plateforme'
`).all(...invParams);
const years = [...new Set([...rows.map(r => r.annee), ...rowsInteretsPlateforme.map(r => r.annee)])]
.sort((a, b) => b.localeCompare(a));
res.json(years);
});
// Filtre commun 2047/2778-SD : seules les plateformes étrangères (domiciliation != FR)
// ET de catégorie "Prestataire de Services de Financement Participatif" (PSFP) sont
// concernées par ces CERFA. Les plateformes "Prestataire de Services sur Actifs
// Numériques" (PSAN) sont exclues même si domiciliées hors de France (demande Olivier
// 30/08/26).
function build2778(annee, invCond, invCondBonus2778, invArg) {
// Plateformes étrangères PSFP × investisseur avec au moins un remboursement sur l'année
const platRows = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
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'
AND p.categorie_plateforme = 'psfp'
ORDER BY p.nom, inv.nom
`).all(invArg, annee);
// Intérêts plateforme (pas d'investissement) sur des plateformes étrangères — même
// logique de fusion que pour le 2561 : rattachés à la ligne plateforme × investisseur.
const platRowsInteretsPlateforme = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
WHERE ${invCondBonus2778}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
`).all(invArg, annee);
const platRowsMap = {};
for (const p of [...platRows, ...platRowsInteretsPlateforme]) {
platRowsMap[`${p.id}_${p.investisseur_id}`] = p;
}
const platRowsMerged = Object.values(platRowsMap).sort((a, b) =>
a.nom.localeCompare(b.nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
// Montants mensuels par plateforme × investisseur
const moisRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
substr(r.date_remb,6,2) AS mois,
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
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
GROUP BY p.id, i.investisseur_id, mois
`).all(invArg, annee);
const moisRowsInteretsPlateforme = db.prepare(`
SELECT
p.id AS plateforme_id,
r.bonus_investisseur_id AS investisseur_id,
substr(r.date_remb,6,2) AS mois,
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
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invCondBonus2778}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.domiciliation != 'FR'
AND p.categorie_plateforme = 'psfp'
GROUP BY p.id, r.bonus_investisseur_id, mois
`).all(invArg, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = round2(m.montant);
}
for (const m of moisRowsInteretsPlateforme) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = round2((moisMap[key][m.mois] ?? 0) + m.montant);
}
const plateformes = platRowsMerged.map(p => ({
id: `${p.id}_${p.investisseur_id}`,
plateforme_id: p.id,
nom: p.nom,
investisseur_id: p.investisseur_id,
investisseur_nom: p.investisseur_nom,
investisseur_prenom: p.investisseur_prenom,
mois: moisMap[`${p.id}_${p.investisseur_id}`] ?? {},
}));
return { annee, plateformes };
}
/* ── 2778-SD — matrice mensuelle par plateforme étrangère ── */
router.get('/2778', (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 invCondBonus2778 = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
res.json(build2778(annee, invCond, invCondBonus2778, invArg));
});
// cf. commentaire au-dessus de build2778 : même filtre PSFP.
function build2047(annee, invCond, invCondBonus2047, invArg) {
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'
AND p.categorie_plateforme = 'psfp'
GROUP BY p.id, inv.id
`).all(invArg, annee);
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'
AND p.categorie_plateforme = 'psfp'
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);
return { annee, lignes, totals };
}
/* ── 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). ── */
/* ── Filtre commun 2042 C PRO (PSAN) : plateformes de catégorie "Prestataire de Services sur
Actifs Numériques" (crypto), quelle que soit leur domiciliation (FR ou étrangère) — contrairement
à 2047/2778-SD (PSFP étrangères uniquement), la doctrine BNC/micro-BNC pour les intérêts/rewards
crypto (staking) s'applique en fonction de la nature du revenu, pas de la localisation de la
plateforme. Pas de crédit d'impôt conventionnel ici (pas de mécanisme 2047) : les recettes sont
déclarées directement en 2042-C PRO, case 5HQ (régime micro-BNC, abattement forfaitaire de 34 %
appliqué automatiquement par l'administration). Demande Olivier 30/08/26. ── */
function build2042CPro(annee, invCond, invCondBonus2042cpro, invArg) {
// Plateformes PSAN × investisseur avec au moins un remboursement sur l'année
const platRows = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
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.categorie_plateforme = 'psan'
ORDER BY p.nom, inv.nom
`).all(invArg, annee);
// Intérêts plateforme (pas d'investissement) sur des plateformes PSAN — même logique de
// fusion que pour le 2778/2047 : rattachés à la ligne plateforme × investisseur.
const platRowsInteretsPlateforme = db.prepare(`
SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
WHERE ${invCondBonus2042cpro}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.categorie_plateforme = 'psan'
`).all(invArg, annee);
const platRowsMap = {};
for (const p of [...platRows, ...platRowsInteretsPlateforme]) {
platRowsMap[`${p.id}_${p.investisseur_id}`] = p;
}
const platRowsMerged = Object.values(platRowsMap).sort((a, b) =>
a.nom.localeCompare(b.nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
// Montants mensuels par plateforme × investisseur — même base que 2047/2778 (intérêts bruts
// avant retenue locale) : il n'y a pas de mécanisme de crédit d'impôt pour la partie BNC/PSAN,
// mais la base de calcul de la recette brute reste cohérente avec le reste de l'app.
const moisRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
substr(r.date_remb,6,2) AS mois,
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
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invCond}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'normal'
AND p.categorie_plateforme = 'psan'
GROUP BY p.id, i.investisseur_id, mois
`).all(invArg, annee);
const moisRowsInteretsPlateforme = db.prepare(`
SELECT
p.id AS plateforme_id,
r.bonus_investisseur_id AS investisseur_id,
substr(r.date_remb,6,2) AS mois,
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
FROM remboursements r
JOIN plateformes p ON p.id = r.bonus_plateforme_id
WHERE ${invCondBonus2042cpro}
AND substr(r.date_remb,1,4) = ?
AND r.statut IN ('paye','partiel')
AND r.type = 'interets_plateforme'
AND p.categorie_plateforme = 'psan'
GROUP BY p.id, r.bonus_investisseur_id, mois
`).all(invArg, annee);
const moisMap = {};
for (const m of moisRows) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = round2(m.montant);
}
for (const m of moisRowsInteretsPlateforme) {
const key = `${m.plateforme_id}_${m.investisseur_id}`;
if (!moisMap[key]) moisMap[key] = {};
moisMap[key][m.mois] = round2((moisMap[key][m.mois] ?? 0) + m.montant);
}
const plateformes = platRowsMerged.map(p => ({
id: `${p.id}_${p.investisseur_id}`,
plateforme_id: p.id,
nom: p.nom,
investisseur_id: p.investisseur_id,
investisseur_nom: p.investisseur_nom,
investisseur_prenom: p.investisseur_prenom,
mois: moisMap[`${p.id}_${p.investisseur_id}`] ?? {},
}));
// Données 2042 C PRO — régime micro-BNC (case 5HQ) : les recettes brutes sont déclarées
// telles quelles, l'administration applique automatiquement l'abattement forfaitaire de 34 %
// pour frais (CGI art. 102 ter). Au-delà du seuil, le régime réel (cases 5KO/5KP) s'applique —
// affiché ici à titre indicatif seulement, l'app ne calcule pas le régime réel (charges réelles
// non suivies dans l'app).
const SEUIL_MICRO_BNC = 77700;
const ABATTEMENT_MICRO_BNC = 0.34;
const totalBrut = round2(plateformes.reduce(
(s, p) => s + Object.values(p.mois).reduce((a, b) => a + b, 0), 0
));
const case_5HQ = Math.round(totalBrut);
const montantImposable = Math.round(case_5HQ * (1 - ABATTEMENT_MICRO_BNC));
return {
annee,
plateformes,
totalBrut,
donnees2042cpro: {
case_5HQ,
abattement_taux: ABATTEMENT_MICRO_BNC,
montant_imposable: montantImposable,
seuil_micro_bnc: SEUIL_MICRO_BNC,
depasse_seuil_micro: case_5HQ > SEUIL_MICRO_BNC,
},
};
}
/* ── 2042 C PRO — Micro-BNC (case 5HQ) — plateformes PSAN (staking / rewards crypto) ──
Contrairement au 2047/2778-SD, ce n'est pas une déclaration de revenus de source étrangère :
les recettes crypto sont des BNC quelle que soit la localisation de la plateforme, reportées
directement en 2042-C PRO sans passer par le 2047 (pas de crédit d'impôt conventionnel). ── */
router.get('/2042cpro', (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 invCondBonus2042cpro = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
res.json(build2042CPro(annee, invCond, invCondBonus2042cpro, invArg));
});
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 invCondBonus2047 = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
const invArg = scopeAll ? req.user.id : req.investisseur.id;
res.json(build2047(annee, invCond, invCondBonus2047, invArg));
});
export { buildCerfa2561, build2778, build2047, build2042CPro };
export default router;