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;