diff --git a/backend/src/db/index.js b/backend/src/db/index.js index bc3c717..488d735 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2576,4 +2576,23 @@ if (!smtpCols.includes('documents_quota_count')) { db.exec('ALTER TABLE smtp_config ADD COLUMN documents_quota_count INTEGER NOT NULL DEFAULT 500'); } +// ── Migration : catégorie de plateforme (PSFP / PSAN) ────────────────────── +// Distingue les plateformes "Prestataire de Services de Financement +// Participatif" (crowdfunding classique) des "Prestataire de Services sur +// Actifs Numériques" (crypto). Valeur définie au niveau du référentiel admin +// et répliquée sur les plateformes utilisateur à la création ; modifiable +// ensuite par l'utilisateur (Paramètres > Plateformes) comme les autres +// champs hérités (cf. HERITABLE_FIELDS / overridden_fields). Demande Olivier +// 30/08/26. +{ + const refColsCatPlat = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name); + if (!refColsCatPlat.includes('categorie_plateforme')) { + db.exec("ALTER TABLE plateformes_referentiel ADD COLUMN categorie_plateforme TEXT NOT NULL DEFAULT 'psfp'"); + } + const platColsCatPlat = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platColsCatPlat.includes('categorie_plateforme')) { + db.exec("ALTER TABLE plateformes ADD COLUMN categorie_plateforme TEXT NOT NULL DEFAULT 'psfp'"); + } +} + export default db; diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index f7efa1d..e33274f 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -54,6 +54,7 @@ const Schema = z.object({ fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'), taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(), type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'), + categorie_plateforme: z.enum(['psfp', 'psan']).default('psfp'), methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).default('portefeuille'), investisseur_id: z.number().int().positive().nullable().optional(), date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), @@ -63,7 +64,7 @@ const Schema = z.object({ }); // Champs hérités du référentiel (comparés pour calculer overridden_fields) -const HERITABLE_FIELDS = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'icone_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; +const HERITABLE_FIELDS = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'categorie_plateforme', 'logo_filename', 'icone_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; /** * Calcule overridden_fields : champs dont la valeur diffère du référentiel. @@ -222,7 +223,7 @@ function syncCategories(platId, catIds) { router.get('/referentiel-list', (_req, res) => { const rows = db.prepare(` SELECT pr.id, pr.nom, pr.url, pr.domiciliation, pr.fiscalite, - pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename, + pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.categorie_plateforme, pr.logo_filename, pr.icone_filename, pr.methode_remboursement, pr.type_pret_defaut, pr.freq_interets_defaut FROM plateformes_referentiel pr ORDER BY pr.nom @@ -233,7 +234,7 @@ router.get('/referentiel-list', (_req, res) => { router.get('/', (req, res) => { const rows = db.prepare(` SELECT p.id, p.nom, p.url, p.notes, p.domiciliation, p.fiscalite, p.taux_fiscalite_locale, - p.type_produit_fiscal, + p.type_produit_fiscal, p.categorie_plateforme, p.methode_remboursement, p.investisseur_id, p.date_ouverture, p.logo_filename, p.icone_filename, p.created_at, p.type_pret_defaut, p.freq_interets_defaut, p.referentiel_id, p.overridden_fields, @@ -283,15 +284,15 @@ router.post('/', (req, res, next) => { const r = db.prepare(` INSERT INTO plateformes - (user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, + (user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, categorie_plateforme, methode_remboursement, investisseur_id, date_ouverture, type_pret_defaut, freq_interets_defaut, logo_filename, icone_filename, referentiel_id, overridden_fields) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( req.user.id, body.nom, body.url || null, body.notes || null, - body.domiciliation, fiscalite, taux, typeProduitFiscal, + body.domiciliation, fiscalite, taux, typeProduitFiscal, body.categorie_plateforme, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null, body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null, logoFilename, iconeFilename, @@ -358,6 +359,7 @@ router.get('/export', (req, res, next) => { fiscalite: r.fiscalite, taux_fiscalite_locale: r.taux_fiscalite_locale, type_produit_fiscal: r.type_produit_fiscal, + categorie_plateforme: r.categorie_plateforme, methode_remboursement: r.methode_remboursement, date_ouverture: r.date_ouverture, type_pret_defaut: r.type_pret_defaut, @@ -426,6 +428,7 @@ router.get('/:id/export', (req, res, next) => { domiciliation: withAll.domiciliation, fiscalite: withAll.fiscalite, taux_fiscalite_locale: withAll.taux_fiscalite_locale, type_produit_fiscal: withAll.type_produit_fiscal, + categorie_plateforme: withAll.categorie_plateforme, methode_remboursement: withAll.methode_remboursement, date_ouverture: withAll.date_ouverture, type_pret_defaut: withAll.type_pret_defaut, freq_interets_defaut: withAll.freq_interets_defaut, @@ -555,7 +558,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { const fields = [ p.url || null, p.notes || null, p.domiciliation || 'france', fiscalite, taux, - p.type_produit_fiscal || '2TT', p.methode_remboursement || 'portefeuille', + p.type_produit_fiscal || '2TT', p.categorie_plateforme || 'psfp', p.methode_remboursement || 'portefeuille', investisseurId, p.date_ouverture || null, p.type_pret_defaut || null, p.freq_interets_defaut || null, p.logo_filename || null, p.icone_filename || null, @@ -564,7 +567,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { if (existing) { db.prepare(` UPDATE plateformes SET url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, - type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, + type_produit_fiscal=?, categorie_plateforme=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, type_pret_defaut=?, freq_interets_defaut=?, logo_filename=?, icone_filename=? WHERE id=? AND user_id=? @@ -575,10 +578,10 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { const r = db.prepare(` INSERT INTO plateformes (user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, - type_produit_fiscal, methode_remboursement, investisseur_id, date_ouverture, + type_produit_fiscal, categorie_plateforme, methode_remboursement, investisseur_id, date_ouverture, type_pret_defaut, freq_interets_defaut, logo_filename, icone_filename, overridden_fields) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run(req.user.id, p.nom, ...fields, '[]'); platId = r.lastInsertRowid; created++; @@ -629,6 +632,7 @@ router.put('/:id', (req, res, next) => { fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal, + categorie_plateforme: body.categorie_plateforme, logo_filename: current.logo_filename, // logo géré séparément methode_remboursement: body.methode_remboursement, type_pret_defaut: body.type_pret_defaut ?? null, @@ -640,13 +644,13 @@ router.put('/:id', (req, res, next) => { const r = db.prepare(` UPDATE plateformes SET nom=?, url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, - type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, + type_produit_fiscal=?, categorie_plateforme=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, type_pret_defaut=?, freq_interets_defaut=?, overridden_fields=? WHERE id=? AND user_id=? `).run( body.nom, body.url || null, body.notes || null, body.domiciliation, fiscalite, taux, - typeProduitFiscal, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null, + typeProduitFiscal, body.categorie_plateforme, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null, body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null, JSON.stringify(overriddenFields), req.params.id, req.user.id @@ -781,12 +785,12 @@ router.post('/:id/reset', (req, res, next) => { db.prepare(` UPDATE plateformes SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, - type_produit_fiscal=?, logo_filename=?, methode_remboursement=?, + type_produit_fiscal=?, categorie_plateforme=?, logo_filename=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?, overridden_fields='[]' WHERE id=? AND user_id=? `).run( ref.nom, ref.url ?? null, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null, - ref.type_produit_fiscal, ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille', + ref.type_produit_fiscal, ref.categorie_plateforme || 'psfp', ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille', ref.type_pret_defaut ?? null, ref.freq_interets_defaut ?? null, req.params.id, req.user.id ); diff --git a/backend/src/routes/referentiel.js b/backend/src/routes/referentiel.js index 57341dc..19610ca 100644 --- a/backend/src/routes/referentiel.js +++ b/backend/src/routes/referentiel.js @@ -77,6 +77,7 @@ const Schema = z.object({ fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'), taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(), type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'), + categorie_plateforme: z.enum(['psfp', 'psan']).default('psfp'), methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).optional().default('portefeuille'), type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(), freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(), @@ -321,6 +322,7 @@ router.get('/export', (req, res, next) => { fiscalite: r.fiscalite, taux_fiscalite_locale: r.taux_fiscalite_locale, type_produit_fiscal: r.type_produit_fiscal, + categorie_plateforme: r.categorie_plateforme, description: r.description, annee_creation: r.annee_creation, investisseurs_types: r.investisseurs_types, @@ -523,7 +525,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { const fields = [ p.url || null, p.domiciliation || 'france', p.fiscalite || 'flat_tax', - p.taux_fiscalite_locale ?? null, p.type_produit_fiscal || '2TT', + p.taux_fiscalite_locale ?? null, p.type_produit_fiscal || '2TT', p.categorie_plateforme || 'psfp', p.methode_remboursement || 'portefeuille', p.type_pret_defaut ?? null, p.freq_interets_defaut ?? null, p.description ?? null, p.annee_creation ?? null, p.investisseurs_types ?? null, @@ -541,7 +543,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { if (existing) { db.prepare(` UPDATE plateformes_referentiel SET - url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, + url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, categorie_plateforme=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?, description=?, annee_creation=?, investisseurs_types=?, regulateur=?, numero_licence=?, is_regule=?, @@ -559,7 +561,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { } else { const r = db.prepare(` INSERT INTO plateformes_referentiel - (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, + (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, categorie_plateforme, methode_remboursement, type_pret_defaut, freq_interets_defaut, description, annee_creation, investisseurs_types, regulateur, numero_licence, is_regule, @@ -569,7 +571,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { garantie_rachat, statistiques_publiques, bonus_inscription, marche_secondaire, investissement_auto, url_trustpilot, url_linkedin, logo_filename, icone_filename, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) `).run(p.nom, ...fields); refId = r.lastInsertRowid; created++; @@ -668,7 +670,7 @@ router.post('/', (req, res, next) => { const r = db.prepare(` INSERT INTO plateformes_referentiel - (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, + (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, categorie_plateforme, methode_remboursement, type_pret_defaut, freq_interets_defaut, logo_filename, description, annee_creation, investisseurs_types, @@ -680,7 +682,7 @@ router.post('/', (req, res, next) => { VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now')) `).run( data.nom, data.url || null, data.domiciliation, data.fiscalite, - data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, + data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, data.categorie_plateforme, data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null, data.logo_filename ?? null, data.description ?? null, data.annee_creation ?? null, data.investisseurs_types ?? null, @@ -722,7 +724,7 @@ router.put('/:id', (req, res, next) => { db.prepare(` UPDATE plateformes_referentiel SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, - type_produit_fiscal=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?, + type_produit_fiscal=?, categorie_plateforme=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?, logo_filename=?, description=?, annee_creation=?, investisseurs_types=?, regulateur=?, numero_licence=?, is_regule=?, @@ -735,7 +737,7 @@ router.put('/:id', (req, res, next) => { WHERE id=? `).run( data.nom, data.url || null, data.domiciliation, data.fiscalite, - data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, + data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, data.categorie_plateforme, data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null, data.logo_filename ?? null, data.description ?? null, data.annee_creation ?? null, data.investisseurs_types ?? null, @@ -814,7 +816,7 @@ router.post('/:id/push', (req, res, next) => { const force = req.body?.force === true; // Champs scalaires poussables - const PUSHABLE = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; + const PUSHABLE = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'categorie_plateforme', 'logo_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; // Catégories/secteurs du référentiel const refCatIds = db.prepare( diff --git a/backend/src/routes/taxreport.js b/backend/src/routes/taxreport.js index 95255e3..182b0d6 100644 --- a/backend/src/routes/taxreport.js +++ b/backend/src/routes/taxreport.js @@ -117,6 +117,15 @@ router.get('/', (req, res) => { 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), @@ -135,7 +144,7 @@ router.get('/', (req, res) => { note: "Les cases sont indicatives. Verifiez avec votre situation fiscale reelle.", }; - res.json({ annee, recap: recapTotal, pertes, pertesTotales: round2(pertesTotales), detail, corrections, cases }); + res.json({ annee, recap: recapTotal, pertes, pertesTotales: round2(pertesTotales), detail, corrections, cases, has_psan: hasPsan, cerfa2042cpro }); }); router.get('/export', (req, res) => { @@ -512,8 +521,13 @@ router.get('/years', (req, res) => { +// 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 × investisseur avec au moins un remboursement sur l'année + // 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 @@ -525,6 +539,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) { 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); @@ -540,6 +555,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) { 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 = {}; @@ -569,6 +585,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) { 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); @@ -590,6 +607,7 @@ function build2778(annee, invCond, invCondBonus2778, invArg) { 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); @@ -636,6 +654,7 @@ router.get('/2778', (req, res) => { 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 @@ -657,6 +676,7 @@ function build2047(annee, invCond, invCondBonus2047, invArg) { 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); @@ -679,6 +699,7 @@ function build2047(annee, invCond, invCondBonus2047, invArg) { 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); @@ -793,6 +814,162 @@ function build2047(annee, invCond, invCondBonus2047, invArg) { É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'; @@ -806,5 +983,5 @@ router.get('/2047', (req, res) => { res.json(build2047(annee, invCond, invCondBonus2047, invArg)); }); -export { buildCerfa2561, build2778, build2047 }; +export { buildCerfa2561, build2778, build2047, build2042CPro }; export default router; diff --git a/backend/src/routes/v1/taxreport.js b/backend/src/routes/v1/taxreport.js index c62913d..1a5101e 100644 --- a/backend/src/routes/v1/taxreport.js +++ b/backend/src/routes/v1/taxreport.js @@ -1,6 +1,6 @@ import { Router } from 'express'; import db from '../../db/index.js'; -import { buildCerfa2561, build2778, build2047 } from '../taxreport.js'; +import { buildCerfa2561, build2778, build2047, build2042CPro } from '../taxreport.js'; const router = Router(); @@ -21,12 +21,22 @@ const sumBy = (arr, key) => arr.reduce((s, x) => s + (x[key] ?? 0), 0); * `synthese_2042` reprend les cases 2TT / 2TR / 2BH / 2CK / 2TY telles qu'affichées * en haut de la page Fiscalité. `cerfa2561` détaille le récapitulatif IFU des * plateformes françaises soumises au PFU. `cerfa2047` (présent seulement si - * `has_etranger`) détaille, par plateforme étrangère, l'éligibilité au crédit - * d'impôt conventionnel (cases 231 à 238 vs 250) et le report vers 8VL/8PL. + * `has_etranger`) détaille, par plateforme étrangère "Prestataire de Services de + * Financement Participatif" (PSFP), l'éligibilité au crédit d'impôt conventionnel + * (cases 231 à 238 vs 250) et le report vers 8VL/8PL. * `cerfa2778_mensuel` (présent seulement si `has_etranger`) donne le détail mensuel - * des intérêts bruts par plateforme étrangère, base de calcul du prélèvement + * des intérêts bruts par plateforme étrangère PSFP, base de calcul du prélèvement * forfaitaire obligatoire (PFO) — le PFO n'est légalement dû que si `pfo_assujetti` * est vrai (seuil de revenu fiscal de référence, cf. Paramétrage → Ma fiscalité). + * Les plateformes "Prestataire de Services sur Actifs Numériques" (PSAN) ne sont + * jamais concernées par les CERFA 2047/2778-SD, même si domiciliées hors de France. + * + * `cerfa2042cpro` (présent seulement si `has_psan`) donne les recettes brutes crypto + * (staking/rewards) des plateformes PSAN, quelle que soit leur domiciliation — imposables + * en BNC, régime micro par défaut (case 5HQ, abattement forfaitaire de 34 % appliqué + * automatiquement par l'administration ; au-delà de 77 700 €, le régime réel 5KO/5KP + * s'applique, non calculé ici). Ce n'est pas un revenu de source étrangère : aucun crédit + * d'impôt, aucun report vers `synthese_2042`. * * Limite connue : `case_2BH` / `case_2CK` de `synthese_2042` supposent qu'aucune * plateforme étrangère n'a été exclue manuellement du calcul PFO dans l'application @@ -67,14 +77,25 @@ router.get('/', (req, res) => { ).get(req.userId); const pfoAssujetti = prefRow ? prefRow.value === 'true' : false; - // Au moins une plateforme domiciliée hors de France, tous détenteurs du foyer confondus - // et indépendamment de l'année — la table `plateformes` est scopée par `user_id` (pas - // `investisseur_id`, qui n'est que le détenteur assigné) dans toute l'application (cf. - // GET /api/plateformes), et hasEtranger côté frontend (TaxReport.jsx) est calculé sur - // cette même liste non filtrée par investisseur actif — un comportement volontairement - // répliqué ici, même avec une clé API mono-investisseur. + // Au moins une plateforme domiciliée hors de France ET de catégorie "Prestataire de + // Services de Financement Participatif" (PSFP), tous détenteurs du foyer confondus et + // indépendamment de l'année — les plateformes "Prestataire de Services sur Actifs + // Numériques" (PSAN) ne sont pas concernées par les CERFA 2047/2778-SD (demande Olivier + // 30/08/26), même si elles sont domiciliées hors de France. La table `plateformes` est + // scopée par `user_id` (pas `investisseur_id`, qui n'est que le détenteur assigné) dans + // toute l'application (cf. GET /api/plateformes), et hasEtranger côté frontend + // (TaxReport.jsx) est calculé sur cette même liste non filtrée par investisseur actif — + // un comportement volontairement répliqué ici, même avec une clé API mono-investisseur. const hasEtranger = !!db.prepare( - `SELECT 1 FROM plateformes WHERE user_id = ? AND domiciliation != 'FR' LIMIT 1` + `SELECT 1 FROM plateformes WHERE user_id = ? AND domiciliation != 'FR' AND categorie_plateforme = 'psfp' LIMIT 1` + ).get(req.userId); + + // Au moins une plateforme "Prestataire de Services sur Actifs Numériques" (PSAN), quelle que + // soit sa domiciliation — les recettes crypto (staking/rewards) sont des BNC de par leur nature, + // pas de par la localisation de la plateforme (contrairement à hasEtranger ci-dessus). Même + // principe de réplication du calcul frontend (TaxReport.jsx) qu'hasEtranger. + const hasPsan = !!db.prepare( + `SELECT 1 FROM plateformes WHERE user_id = ? AND categorie_plateforme = 'psan' LIMIT 1` ).get(req.userId); const cerfa2561 = buildCerfa2561(annee, invCond, invCondBonus, invParam); @@ -86,6 +107,11 @@ router.get('/', (req, res) => { cerfa2778_mensuel = build2778(annee, invCond, invCondBonus, invParam); } + let cerfa2042cpro = null; + if (hasPsan) { + cerfa2042cpro = build2042CPro(annee, invCond, invCondBonus, invParam); + } + // ── Synthèse 2042 — mêmes cases et mêmes formules que le pavé "Cases fiscales 2042 — // synthèse" en haut de la page Fiscalité (TaxReport.jsx, fonction load()) : // - 2TT : cumul direct des lignes 2561 françaises. 2TY (pertes en capital, @@ -115,11 +141,13 @@ router.get('/', (req, res) => { res.json({ annee, has_etranger: hasEtranger, + has_psan: hasPsan, pfo_assujetti: pfoAssujetti, synthese_2042, cerfa2561, cerfa2047, cerfa2778_mensuel, + cerfa2042cpro, }); }); diff --git a/frontend/src/components/Cerfa2042CProPreview.jsx b/frontend/src/components/Cerfa2042CProPreview.jsx new file mode 100644 index 0000000..a6ca9dd --- /dev/null +++ b/frontend/src/components/Cerfa2042CProPreview.jsx @@ -0,0 +1,266 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api.js'; +import { MOIS_LABELS, MOIS_NUMS, r, fmtEUR } from './Cerfa2778Preview.jsx'; + +// Cases CERFA : montants arrondis à l'euro le plus proche (règle fiscale), sans décimales — +// contrairement au Suivi mensuel (intermédiaire, non déclaré tel quel) qui garde 2 décimales. +const fmt0 = v => { + if (v == null || v === 0) return - €; + return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €`; +}; + +function CaseBadge({ code, tone }) { + const success = tone === 'success'; + return ( + {code} + ); +} + +/* ── Suivi mensuel — table plateforme × mois, sans les lignes PFO/2778-SD (non applicables : les + recettes PSAN sont des BNC, pas des intérêts soumis au prélèvement forfaitaire obligatoire). ── */ +function SuiviMensuelPsan({ annee, plateformes, totauxMois }) { + const cellStyle = { + textAlign: 'right', padding: '6px 10px', fontSize: 'var(--fs-sm)', + whiteSpace: 'nowrap', borderBottom: '1px solid var(--border)', + }; + const headStyle = { + textAlign: 'center', padding: '6px 10px', fontSize: 'var(--fs-xs)', fontWeight: 600, + color: 'var(--text-muted)', background: 'var(--surface-2)', + borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap', + }; + return ( +
+
+

Suivi mensuel des recettes crypto (BNC)

+ Revenus {annee} — plateformes PSAN +
+ + + + + {MOIS_LABELS.map(m => )} + + + + + {plateformes.map(plat => { + const total = Object.values(plat.mois).reduce((a, b) => a + b, 0); + return ( + + + {MOIS_NUMS.map(m => )} + + + ); + })} + + + + {totauxMois.map((t, i) => )} + + + +
+ Plateforme — Détenteur + {m}Total
+ {plat.nom} + {(plat.investisseur_nom || plat.investisseur_prenom) && ( + + — {(() => { const n = plat.investisseur_nom ?? ''; const p = plat.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()} + + )} + {fmtEUR(plat.mois[m] ?? null)}{fmtEUR(r(total))}
+ Total recettes brutes + {fmtEUR(t || null)}{fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0)))}
+
+ Base : intérêts/rewards bruts encaissés (valorisés en euros au jour de réception), avant tout abattement. +
+
+ ); +} + +/* ── Composant principal ── */ +export default function Cerfa2042CProPreview({ annee, activeView }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [view, setView] = useState('matrice'); // 'matrice' | 'donnees' + + useEffect(() => { + setLoading(true); + setData(null); + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + api.get('/taxreport/2042cpro', { annee, ...scopeParams }) + .then(setData) + .finally(() => setLoading(false)); + }, [annee, activeView]); + + if (loading || !data) return
Chargement…
; + + const plateformes = data.plateformes ?? []; + + if (plateformes.length === 0) { + return ( +
+

+ Aucune recette crypto (plateforme PSAN) pour {annee}. +

+
+ ); + } + + const totauxMois = MOIS_NUMS.map(m => r(plateformes.reduce((s, p) => s + (p.mois[m] ?? 0), 0))); + const d = data.donnees2042cpro; + + const platLabel = (p) => { + const n = p.investisseur_nom ?? ''; + const pr = p.investisseur_prenom ?? ''; + const who = pr && n.startsWith(pr) ? n : [pr, n].filter(Boolean).join(' '); + return ( + <> + {p.nom} + {who && — {who}} + + ); + }; + + const cellStyle = { + textAlign: 'right', padding: '7px 10px', fontSize: 'var(--fs-sm)', + whiteSpace: 'nowrap', borderBottom: '1px solid var(--border)', + }; + const headStyle = { + textAlign: 'center', padding: '7px 10px', fontSize: 'var(--fs-xs)', fontWeight: 600, + color: 'var(--text-muted)', background: 'var(--surface-2)', + borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap', + }; + + return ( +
+
+ + +
+ + {/* ── Vue Suivi mensuel ── */} + {view === 'matrice' && ( + + )} + + {/* ── Vue Données 2042 C PRO ── */} + {view === 'donnees' && ( +
+
+

Bénéfices non commerciaux — Régime micro-BNC

+ Vision annuelle {annee} — plateformes PSAN (staking / rewards crypto) +
+ +
+
+ Recettes brutes par plateforme +
+
+ + + + + + + + + {plateformes.map(p => { + const total = r(Object.values(p.mois).reduce((a, b) => a + b, 0)); + return ( + + + + + ); + })} + + + + + +
Plateforme — DétenteurRecette brute {annee}
{platLabel(p)}{fmt0(Math.round(total))}
Total{fmt0(d.case_5HQ)}
+ +
+
+
+ Micro-BNC — Recettes à reporter en 2042-C PRO +
+
+ Montant brut, sans déduire l'abattement — celui-ci ({Math.round(d.abattement_taux * 100)} %) est appliqué automatiquement par l'administration. + Bénéfice imposable indicatif : {fmt0(d.montant_imposable)}. +
+
+
{fmt0(d.case_5HQ)}
+
+ + {d.depasse_seuil_micro && ( +
+ ⚠ Recettes supérieures au seuil du régime micro-BNC ({fmt0(d.seuil_micro_bnc)}) : le régime réel s'applique + (cases 5KO bénéfice / 5KP déficit), avec déduction des charges réelles + (non calculées par l'application) plutôt que l'abattement forfaitaire de {Math.round(d.abattement_taux * 100)} %. +
+ )} + +
+ Les intérêts/rewards perçus via des plateformes de type « Prestataire de Services sur Actifs Numériques » + (staking, récompenses crypto) sont imposés en bénéfices non commerciaux (BNC), quelle que soit la + localisation de la plateforme — il ne s'agit pas d'un revenu de source étrangère : contrairement au 2047/2778-SD, + ce montant n'ouvre droit à aucun crédit d'impôt et ne se déclare pas via l'imprimé n° 2047. Il se reporte + directement sur la déclaration n° 2042-C PRO, rubrique « Régime micro BNC ». +
+
+ )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/AdminPlateformes.jsx b/frontend/src/pages/AdminPlateformes.jsx index ef3d535..408a683 100644 --- a/frontend/src/pages/AdminPlateformes.jsx +++ b/frontend/src/pages/AdminPlateformes.jsx @@ -44,7 +44,8 @@ function IconUpload() { /* ── Constantes ───────────────────────────────────────────────── */ const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—'; const FISCALITE_LABELS = { flat_tax: 'Flat tax (30%)', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale' }; -const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] }; +const CATEGORIE_PLATEFORME_LABELS = { psfp: 'Prestataire de Services de Financement Participatif', psan: 'Prestataire de Services sur Actifs Numériques' }; +const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] }; /* ── Modal création / édition référentiel ─────────────────────── */ function RefModal({ row, onClose, onSaved }) { @@ -59,6 +60,7 @@ function RefModal({ row, onClose, onSaved }) { fiscalite: row.fiscalite || 'flat_tax', taux_fiscalite_locale: row.taux_fiscalite_locale != null ? String(row.taux_fiscalite_locale) : '', type_produit_fiscal: row.type_produit_fiscal || '2TT', + categorie_plateforme: row.categorie_plateforme || 'psfp', methode_remboursement: row.methode_remboursement || 'portefeuille', type_pret_defaut: row.type_pret_defaut || '', freq_interets_defaut: row.freq_interets_defaut || '', @@ -93,6 +95,7 @@ function RefModal({ row, onClose, onSaved }) { taux_fiscalite_locale: fiscalite === 'avec_fiscalite_locale' && form.taux_fiscalite_locale !== '' ? Number(form.taux_fiscalite_locale) : null, type_produit_fiscal: form.type_produit_fiscal, + categorie_plateforme: form.categorie_plateforme || 'psfp', methode_remboursement: form.methode_remboursement || 'portefeuille', type_pret_defaut: form.type_pret_defaut || null, freq_interets_defaut: form.freq_interets_defaut || null, @@ -181,6 +184,13 @@ function RefModal({ row, onClose, onSaved }) { +
+ + +
diff --git a/frontend/src/pages/PlatformeProfile.jsx b/frontend/src/pages/PlatformeProfile.jsx index 7c57f22..2bce517 100644 --- a/frontend/src/pages/PlatformeProfile.jsx +++ b/frontend/src/pages/PlatformeProfile.jsx @@ -35,6 +35,7 @@ const INV_TYPES = { les_deux: 'Particulier & Entreprise', }; const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—'; +const CATEGORIE_PLATEFORME_LABELS = { psfp: 'Prestataire de Services de Financement Participatif', psan: 'Prestataire de Services sur Actifs Numériques' }; // ── Composants helpers ──────────────────────────────────────────────────────── @@ -174,6 +175,7 @@ export default function PlatformeProfile() { fiscalite: form.fiscalite, taux_fiscalite_locale: form.taux_fiscalite_locale !== '' && form.taux_fiscalite_locale != null ? Number(form.taux_fiscalite_locale) : null, type_produit_fiscal: form.type_produit_fiscal || '2TT', + categorie_plateforme: form.categorie_plateforme || 'psfp', logo_filename: data.logo_filename ?? null, description: form.description || null, categories: data.categories || [], @@ -275,6 +277,9 @@ export default function PlatformeProfile() { {countryLabel(data.domiciliation)} )} + + {CATEGORIE_PLATEFORME_LABELS[data.categorie_plateforme] ?? CATEGORIE_PLATEFORME_LABELS.psfp} +
{editing ?