Catégorisation des platformes et application de la fiscalité adapté (Impact PSAN , régime micro-BIC)

This commit is contained in:
ocroguennec committed 2026-08-30 16:53:04 +02:00
1 parent 8953d0bb35
commit 33fee587aa
10 files changed
+610 -53

No files matched your search

+19
View File
@@ -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;
+18 -14
View File
@@ -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
);
+11 -9
View File
@@ -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(
+180 -3
View File
@@ -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;
+39 -11
View File
@@ -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,
});
});