988 lines
43 KiB
JavaScript
988 lines
43 KiB
JavaScript
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;
|