MAJ
This commit is contained in:
1 parent
14cf4e5a19
commit
26197fc411
15 files changed
+1776
-29
No files matched your search
@@ -3490,4 +3490,38 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissem
|
||||
}
|
||||
}
|
||||
|
||||
// Chantier "Fiscalité PE" (20/09/26, demande Olivier) ───────────────────────────────────────
|
||||
//
|
||||
// 1) Troisième nature de distribution : 'plus_value' (plus-value réalisée, distribuée par le
|
||||
// fonds au fil des cessions de son portefeuille sous-jacent — cf. échange avec Olivier :
|
||||
// contrairement à un compte-titres classique où l'on "vend" soi-même sa position en un seul
|
||||
// événement, un fonds PE (FCPR/FPCI) restitue le produit de ses propres cessions par
|
||||
// distributions successives). Choix : rattacher la plus-value réalisée aux distributions
|
||||
// existantes plutôt que de construire un événement "cession" séparé (option écartée) — colle
|
||||
// au fonctionnement réel et réutilise le mécanisme déjà en place (montant saisi dans
|
||||
// interets_bruts, comme 'interet_non_deploye', cf. RembFormModal.jsx / RemboursementsSimple.jsx).
|
||||
// Aucune migration de schéma nécessaire : distribution_nature est un TEXT libre sans CHECK
|
||||
// (cf. commentaire ci-dessus), donc 'plus_value' est déjà une valeur valide en base — seule
|
||||
// la validation Zod (remboursements.js) et les libellés frontend doivent la reconnaître.
|
||||
//
|
||||
// 2) Pertes définitives PE : jusqu'ici investissements_pe.statut acceptait déjà la valeur
|
||||
// 'perte_definitive' (cf. investissementsPe.js, Zod), mais rien ne permettait d'en tracer le
|
||||
// montant/la date/le motif pour la fiscalité — contrairement au crowdlending
|
||||
// (investissement_pertes, case 2TY). Table analogue, même structure, adaptée à
|
||||
// investissements_pe (pas de notion "ancien_statut" en_retard/procedure côté PE : un deal PE
|
||||
// passe directement de 'valide'/'cloture' à 'perte_definitive', jamais via un statut
|
||||
// intermédiaire de recouvrement).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS investissement_pe_pertes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
|
||||
date_effet TEXT NOT NULL,
|
||||
montant_perte REAL NOT NULL CHECK(montant_perte >= 0),
|
||||
ancien_statut TEXT,
|
||||
motif TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_pe_pertes_inv ON investissement_pe_pertes(investissement_pe_id)');
|
||||
|
||||
export default db;
|
||||
@@ -48,6 +48,17 @@ const Schema = z.object({
|
||||
mode_detention: z.enum(['direct', 'pea_pme', 'assurance_vie']).default('direct'),
|
||||
});
|
||||
|
||||
// Déclaration d'une perte définitive de capital sur un deal PE (20/09/26, chantier
|
||||
// "Fiscalité PE" — cf. migration investissement_pe_pertes dans db/index.js). Même schéma que
|
||||
// PerteSchema côté crowdlending (investissements.js) : montant_perte éditable (perte partielle
|
||||
// possible si une partie du capital a déjà été recouvrée), préfilli côté frontend mais non
|
||||
// recalculé ici.
|
||||
const PerteSchema = z.object({
|
||||
date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
montant_perte: z.number().nonnegative(),
|
||||
motif: z.string().trim().min(1, 'Le motif est obligatoire'),
|
||||
});
|
||||
|
||||
const ValorisationSchema = z.object({
|
||||
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
valorisation_nette: z.number().nonnegative(),
|
||||
@@ -402,7 +413,18 @@ router.get('/:id', (req, res, next) => {
|
||||
-- dans le TVPI (valeur réellement perçue), juste isolé ici pour l'affichage détaillé.
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'interet_non_deploye'), 0)
|
||||
AS obligation_cumulee
|
||||
AS obligation_cumulee,
|
||||
-- Idem, pour la part "plus-value réalisée" des distributions (20/09/26, chantier
|
||||
-- "Fiscalité PE" — cf. distribution_nature dans RembFormModal.jsx). Inclus dans
|
||||
-- distributions_cumulees ET dans le TVPI, isolé ici pour l'affichage/le calcul fiscal.
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'plus_value'), 0)
|
||||
AS plus_value_cumulee,
|
||||
-- Idem, pour la part "restitution de capital déployé" — sert à préfiller le montant par
|
||||
-- défaut de la modale "Déclarer une perte définitive" (capital pas encore restitué).
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id AND r.distribution_nature = 'capital'), 0)
|
||||
AS capital_restitue_cumule
|
||||
FROM investissements_pe ipe
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
@@ -412,6 +434,13 @@ router.get('/:id', (req, res, next) => {
|
||||
row.tvpi = row.derniere_valorisation != null
|
||||
? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100
|
||||
: null;
|
||||
// Pertes définitives (chantier "Fiscalité PE", 20/09/26) — au plus une ligne active à la
|
||||
// fois en pratique (statut repasse à perte_definitive), mais l'historique complet
|
||||
// (déclarations annulées comprises) est conservé pour traçabilité, comme
|
||||
// investissement_pertes côté crowdlending.
|
||||
row.pertes = db.prepare(
|
||||
'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id ASC'
|
||||
).all(id);
|
||||
res.json(row);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
@@ -547,6 +576,67 @@ router.delete('/:id', requireInvestisseur, (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* ── Perte définitive (chantier "Fiscalité PE", 20/09/26) ───────
|
||||
* POST /:id/perte-definitive — déclare un deal en perte définitive (capital réellement
|
||||
* irrécouvrable : fonds liquidé sans distribution, société sous-jacente radiée…).
|
||||
* Contrairement au crowdlending, un deal PE n'a pas de statut intermédiaire de
|
||||
* recouvrement (en_retard/procedure) : n'importe quel statut autre que
|
||||
* 'perte_definitive' peut y transiter directement.
|
||||
* DELETE /:id/perte-definitive — annule la dernière déclaration, restaure le statut précédent.
|
||||
*/
|
||||
router.post('/:id/perte-definitive', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const body = PerteSchema.parse(req.body);
|
||||
const id = Number(req.params.id);
|
||||
const deal = ownedDeal(req, id);
|
||||
if (deal.statut === 'perte_definitive') {
|
||||
throw new HttpError(400, 'Ce deal est déjà déclaré en perte définitive.');
|
||||
}
|
||||
|
||||
let perteId;
|
||||
const tx = db.transaction(() => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO investissement_pe_pertes (investissement_pe_id, date_effet, montant_perte, ancien_statut, motif)
|
||||
VALUES (?,?,?,?,?)
|
||||
`).run(id, body.date_effet, body.montant_perte, deal.statut, body.motif);
|
||||
perteId = r.lastInsertRowid;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE investissements_pe SET statut = 'perte_definitive', updated_at = datetime('now') WHERE id = ?
|
||||
`).run(id);
|
||||
});
|
||||
tx();
|
||||
|
||||
const perte = db.prepare('SELECT * FROM investissement_pe_pertes WHERE id = ?').get(perteId);
|
||||
res.status(201).json(perte);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/:id/perte-definitive', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const deal = ownedDeal(req, id);
|
||||
if (deal.statut !== 'perte_definitive') {
|
||||
throw new HttpError(400, "Ce deal n'est pas déclaré en perte définitive.");
|
||||
}
|
||||
|
||||
const derniere = db.prepare(
|
||||
'SELECT * FROM investissement_pe_pertes WHERE investissement_pe_id = ? ORDER BY id DESC LIMIT 1'
|
||||
).get(id);
|
||||
const statutRestaure = derniere?.ancien_statut || 'valide';
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (derniere) db.prepare('DELETE FROM investissement_pe_pertes WHERE id = ?').run(derniere.id);
|
||||
db.prepare(`
|
||||
UPDATE investissements_pe SET statut = ?, updated_at = datetime('now') WHERE id = ?
|
||||
`).run(statutRestaure, id);
|
||||
});
|
||||
tx();
|
||||
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* ── Historique des valorisations ────────────────────────────── */
|
||||
|
||||
router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => {
|
||||
|
||||
@@ -20,12 +20,16 @@ const Schema = z.object({
|
||||
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
|
||||
// Sans effet sur le type 'normal' (crowdlending).
|
||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
||||
// Nature de la distribution pour un deal PE (18/09/26, demande Olivier) : 'capital' = restitution
|
||||
// du capital déployé, 'interet_non_deploye' = intérêt versé sur le capital NON déployé (support
|
||||
// obligataire du fonds) — cf. migration distribution_nature dans db/index.js. Optionnel : une
|
||||
// distribution non classée reste comptée dans "Distributions" (pas "Obligation") côté PE. Sans
|
||||
// effet hors type 'interets_plateforme'.
|
||||
distribution_nature: z.enum(['capital', 'interet_non_deploye']).nullable().optional(),
|
||||
// Nature de la distribution pour un deal PE (18/09/26, demande Olivier ; étendue le 20/09/26,
|
||||
// chantier "Fiscalité PE") : 'capital' = restitution du capital déployé (non imposable),
|
||||
// 'interet_non_deploye' = intérêt versé sur le capital NON déployé (support obligataire du
|
||||
// fonds, imposable comme un revenu), 'plus_value' = plus-value réalisée distribuée par le
|
||||
// fonds au fil de ses propres cessions (imposable, mais reportée dans une case différente de
|
||||
// 'interet_non_deploye' sur la déclaration — cf. taxreportPe.js) — cf. migration
|
||||
// distribution_nature dans db/index.js. Optionnel : une distribution non classée reste comptée
|
||||
// dans "Distributions" (pas "Obligation"/"Plus-value") côté PE. Sans effet hors type
|
||||
// 'interets_plateforme'.
|
||||
distribution_nature: z.enum(['capital', 'interet_non_deploye', 'plus_value']).nullable().optional(),
|
||||
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
capital: z.number().nonnegative().default(0),
|
||||
cashback: z.number().nonnegative().default(0),
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const round2 = v => Math.round((v ?? 0) * 100) / 100;
|
||||
const esc = v => String(v ?? '').replace(/;/g, ',').replace(/[\r\n]+/g, ' ');
|
||||
|
||||
/**
|
||||
* /api/taxreport-pe — Fiscalité Private Equity (chantier du 20/09/26, demande Olivier :
|
||||
* "Nous n'avons pas fait la partie Fiscalité pour les PE").
|
||||
*
|
||||
* Contrairement au crowdlending (taxreport.js), où chaque remboursement porte déjà ses
|
||||
* propres prélev_sociaux/prelev_forfaitaire calculés à partir de la fiscalité de la
|
||||
* plateforme, la fiscalité PE dépend avant tout du **mode de détention** du deal
|
||||
* (investissements_pe.mode_detention — direct / PEA-PME / assurance-vie), trois régimes
|
||||
* fiscaux très différents entre eux (choix explicite d'Olivier : "j'ai besoin de
|
||||
* différencier les 3"). Cette route agrège donc les mêmes montants que
|
||||
* RemboursementsSimple.jsx / InvestissementPeDetail.jsx, mais groupés par mode_detention
|
||||
* plutôt que par plateforme.
|
||||
*
|
||||
* Décisions de conception validées avec Olivier avant implémentation :
|
||||
* - La plus-value réalisée n'est PAS un événement "cession" séparé : elle est portée par
|
||||
* les distributions existantes (remboursements.distribution_nature = 'plus_value'),
|
||||
* au même titre que 'capital' (restitution non imposable) et 'interet_non_deploye'
|
||||
* (revenu imposable sur le capital non déployé) — cf. migration dans db/index.js.
|
||||
* - Les pertes définitives (investissement_pe_pertes, cf. investissementsPe.js
|
||||
* POST/DELETE /:id/perte-definitive) sont reportées par mode_detention mais jamais
|
||||
* imputées automatiquement sur les plus-values : comme taxreport.js côté crowdlending,
|
||||
* cette route reste indicative — c'est à l'utilisateur d'arbitrer l'imputation réelle
|
||||
* sur sa déclaration.
|
||||
* - direct : PFU (12,8 % IR + 17,2 % PS, taux annuels dans taux_pfu) appliqué séparément
|
||||
* sur la plus-value réalisée (case 3VG, formulaire 2074) et sur l'intérêt non déployé
|
||||
* (case 2TR, revenus de capitaux mobiliers) — même taux global, mais deux cases
|
||||
* différentes sur la déclaration, donc jamais fusionnées dans un seul montant.
|
||||
* - PEA-PME : seuls les prélèvements sociaux (17,2 %) sont calculés — l'impôt sur le
|
||||
* revenu est nativement exonéré tant que les conditions du plan sont respectées
|
||||
* (retrait après 5 ans). Pas de case précise : en pratique, l'établissement teneur du
|
||||
* PEA-PME précompte lui-même les PS au retrait ; ce montant reste indicatif pour
|
||||
* vérification.
|
||||
* - assurance-vie : aucun taux n'est appliqué automatiquement — la fiscalité dépend de
|
||||
* l'ancienneté du contrat et du cumul des primes versées (au-delà de 150 000 €),
|
||||
* des éléments que l'application ne connaît pas. Seuls les montants bruts sont
|
||||
* exposés, à traiter avec votre assureur/expert fiscal.
|
||||
*
|
||||
* Comme taxreport.js : "Les cases sont indicatives. Vérifiez avec votre situation fiscale
|
||||
* réelle." — cette route effectue des calculs déterministes à partir de règles convenues
|
||||
* avec Olivier, ce n'est pas un avis fiscal.
|
||||
*/
|
||||
|
||||
const MODES = ['direct', 'pea_pme', 'assurance_vie'];
|
||||
|
||||
function resolveScope(req, res) {
|
||||
const scopeAll = req.query.scope === 'all';
|
||||
if (scopeAll) return { invCond: 'inv.user_id = ?', invArgs: [req.user.id] };
|
||||
const raw = req.header('X-Investisseur-Id');
|
||||
const id = Number(raw);
|
||||
if (!id) { res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); return null; }
|
||||
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!row) { res.status(403).json({ error: 'Investisseur not found or not owned by user' }); return null; }
|
||||
return { invCond: 'inv.id = ?', invArgs: [id] };
|
||||
}
|
||||
|
||||
function emptyBucket() {
|
||||
return {
|
||||
plus_value_realisee: 0,
|
||||
interet_non_deploye: 0,
|
||||
capital_restitue: 0,
|
||||
non_classe: 0,
|
||||
pertes_definitives: 0,
|
||||
frais_gestion: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /years — années disponibles pour le sélecteur (mêmes sources que GET /), même principe
|
||||
// que GET /taxreport/years côté crowdlending.
|
||||
router.get('/years', (req, res) => {
|
||||
const scope = resolveScope(req, res);
|
||||
if (!scope) return;
|
||||
const { invCond, invArgs } = scope;
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT DISTINCT strftime('%Y', r.date_remb) AS annee
|
||||
FROM remboursements r
|
||||
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond} AND ipe.workspace_id = ? AND r.type = 'interets_plateforme'
|
||||
UNION
|
||||
SELECT DISTINCT strftime('%Y', ip.date_effet) AS annee
|
||||
FROM investissement_pe_pertes ip
|
||||
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond} AND ipe.workspace_id = ?
|
||||
ORDER BY annee DESC
|
||||
`).all(...invArgs, workspaceId, ...invArgs, workspaceId);
|
||||
|
||||
const years = rows.map(r => r.annee).filter(Boolean);
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (!years.includes(String(currentYear))) years.unshift(String(currentYear));
|
||||
if (!years.includes(String(currentYear - 1))) years.push(String(currentYear - 1));
|
||||
res.json([...new Set(years)].sort((a, b) => b.localeCompare(a)));
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const annee = req.query.annee || String(new Date().getFullYear() - 1);
|
||||
const scope = resolveScope(req, res);
|
||||
if (!scope) return;
|
||||
const { invCond, invArgs } = scope;
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
// Distributions (remboursements de type interets_plateforme, rattachées à un deal PE précis
|
||||
// — seul cas où mode_detention est connu). Les distributions non rattachées à un deal
|
||||
// (investissement_pe_id NULL) sont regroupées à part : leur régime fiscal est indéterminable.
|
||||
const distribRows = db.prepare(`
|
||||
SELECT ipe.mode_detention, r.distribution_nature, r.net_recu
|
||||
FROM remboursements r
|
||||
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
`).all(...invArgs, workspaceId, annee);
|
||||
|
||||
const distribNonRattacheesRows = db.prepare(`
|
||||
SELECT r.distribution_nature, r.net_recu
|
||||
FROM remboursements r
|
||||
JOIN plateformes p ON p.id = r.bonus_plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = r.bonus_investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
AND r.investissement_pe_id IS NULL
|
||||
AND r.workspace_id = ?
|
||||
`).all(...invArgs, annee, workspaceId);
|
||||
|
||||
// Pertes définitives par mode de détention.
|
||||
const pertesRows = db.prepare(`
|
||||
SELECT ipe.mode_detention, ip.montant_perte, ip.date_effet, ip.motif,
|
||||
ipe.nom_deal, p.nom AS plateforme_nom
|
||||
FROM investissement_pe_pertes ip
|
||||
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND substr(ip.date_effet,1,4) = ?
|
||||
`).all(...invArgs, workspaceId, annee);
|
||||
|
||||
// Frais de gestion (informatifs — pas de déduction automatique appliquée, la déductibilité
|
||||
// des frais de gestion PE dépend de cas particuliers hors du périmètre de ce calcul).
|
||||
const fraisRows = db.prepare(`
|
||||
SELECT ipe.mode_detention, f.montant
|
||||
FROM frais_operations f
|
||||
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND substr(f.date_operation,1,4) = ?
|
||||
AND f.categorie = 'gestion'
|
||||
`).all(...invArgs, workspaceId, annee);
|
||||
|
||||
const buckets = { direct: emptyBucket(), pea_pme: emptyBucket(), assurance_vie: emptyBucket() };
|
||||
const nonRattachees = emptyBucket();
|
||||
|
||||
const addDistrib = (bucket, nature, montant) => {
|
||||
if (nature === 'plus_value') bucket.plus_value_realisee += montant;
|
||||
else if (nature === 'interet_non_deploye') bucket.interet_non_deploye += montant;
|
||||
else if (nature === 'capital') bucket.capital_restitue += montant;
|
||||
else bucket.non_classe += montant;
|
||||
};
|
||||
|
||||
for (const r of distribRows) {
|
||||
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
|
||||
addDistrib(bucket, r.distribution_nature, r.net_recu || 0);
|
||||
}
|
||||
for (const r of distribNonRattacheesRows) {
|
||||
addDistrib(nonRattachees, r.distribution_nature, r.net_recu || 0);
|
||||
}
|
||||
for (const r of pertesRows) {
|
||||
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
|
||||
bucket.pertes_definitives += r.montant_perte || 0;
|
||||
}
|
||||
for (const r of fraisRows) {
|
||||
const bucket = buckets[MODES.includes(r.mode_detention) ? r.mode_detention : 'direct'];
|
||||
bucket.frais_gestion += r.montant || 0;
|
||||
}
|
||||
|
||||
// Taux PFU de l'année (mêmes taux que le crowdlending, table partagée taux_pfu).
|
||||
const pfuRates = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(Number(annee))
|
||||
?? db.prepare('SELECT * FROM taux_pfu ORDER BY annee DESC LIMIT 1').get()
|
||||
?? null;
|
||||
|
||||
const fiscal = {};
|
||||
for (const mode of MODES) {
|
||||
const b = buckets[mode];
|
||||
if (mode === 'direct') {
|
||||
fiscal[mode] = {
|
||||
prelev_sociaux_plus_value: pfuRates ? round2(b.plus_value_realisee * pfuRates.prelev_sociaux / 100) : null,
|
||||
prelev_forfaitaire_plus_value: pfuRates ? round2(b.plus_value_realisee * pfuRates.impot_revenu / 100) : null,
|
||||
prelev_sociaux_interets: pfuRates ? round2(b.interet_non_deploye * pfuRates.prelev_sociaux / 100) : null,
|
||||
prelev_forfaitaire_interets: pfuRates ? round2(b.interet_non_deploye * pfuRates.impot_revenu / 100) : null,
|
||||
};
|
||||
} else if (mode === 'pea_pme') {
|
||||
const baseImposable = b.plus_value_realisee + b.interet_non_deploye;
|
||||
fiscal[mode] = {
|
||||
prelev_sociaux: pfuRates ? round2(baseImposable * pfuRates.prelev_sociaux / 100) : null,
|
||||
note: "Impôt sur le revenu nativement exonéré si les conditions du PEA-PME sont respectées (retrait après 5 ans) — seuls les prélèvements sociaux sont indicatifs ici.",
|
||||
};
|
||||
} else {
|
||||
fiscal[mode] = {
|
||||
note: "Fiscalité dépendant de l'ancienneté du contrat et du cumul des primes versées — aucun taux n'est appliqué automatiquement. Vérifiez avec votre assureur ou votre expert fiscal.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Important (correction 20/09/26, après vérification des libellés IFU réels dans
|
||||
// Cerfa2561Preview.jsx côté crowdlending) : une perte définitive sur un deal PE n'est PAS
|
||||
// une "perte en capital sur prêt participatif" (case 2TY du 2042, régime spécifique à l'art.
|
||||
// 125-00 A du CGI réservé aux prêts de financement participatif — cf. KS/2TY dans
|
||||
// Cerfa2561Preview.jsx, jamais utilisé pour un produit "à revenu fixe" ordinaire). Un deal PE
|
||||
// est une part de fonds (valeur mobilière), donc sa perte définitive est une MOINS-VALUE DE
|
||||
// CESSION (cession à prix nul), qui relève du même formulaire 2074 que la plus-value réalisée
|
||||
// (imputable uniquement sur des plus-values mobilières, pas sur le revenu global) — jamais de
|
||||
// la famille "2TR / IFU". D'où case_3VH_direct (et non case_2TY_direct) ci-dessous.
|
||||
const cases = {
|
||||
case_3VG_direct: round2(buckets.direct.plus_value_realisee),
|
||||
case_2TR_direct: round2(buckets.direct.interet_non_deploye),
|
||||
case_3VH_direct: round2(buckets.direct.pertes_definitives),
|
||||
pea_pme_base_imposable_ps: round2(buckets.pea_pme.plus_value_realisee + buckets.pea_pme.interet_non_deploye),
|
||||
pea_pme_pertes: round2(buckets.pea_pme.pertes_definitives),
|
||||
assurance_vie_brut: round2(buckets.assurance_vie.plus_value_realisee + buckets.assurance_vie.interet_non_deploye),
|
||||
assurance_vie_pertes: round2(buckets.assurance_vie.pertes_definitives),
|
||||
note: "Les cases sont indicatives. Vérifiez avec votre situation fiscale réelle.",
|
||||
};
|
||||
|
||||
res.json({
|
||||
annee,
|
||||
par_mode_detention: {
|
||||
direct: { ...round2Bucket(buckets.direct), fiscal: fiscal.direct },
|
||||
pea_pme: { ...round2Bucket(buckets.pea_pme), fiscal: fiscal.pea_pme },
|
||||
assurance_vie: { ...round2Bucket(buckets.assurance_vie), fiscal: fiscal.assurance_vie },
|
||||
},
|
||||
non_rattachees: round2Bucket(nonRattachees),
|
||||
pertes_detail: pertesRows.map(r => ({ ...r, montant_perte: round2(r.montant_perte) })),
|
||||
has_pfu_rates: !!pfuRates,
|
||||
cases,
|
||||
});
|
||||
});
|
||||
|
||||
function round2Bucket(b) {
|
||||
return {
|
||||
plus_value_realisee: round2(b.plus_value_realisee),
|
||||
interet_non_deploye: round2(b.interet_non_deploye),
|
||||
capital_restitue: round2(b.capital_restitue),
|
||||
non_classe: round2(b.non_classe),
|
||||
pertes_definitives: round2(b.pertes_definitives),
|
||||
frais_gestion: round2(b.frais_gestion),
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/export', (req, res) => {
|
||||
const annee = req.query.annee || String(new Date().getFullYear() - 1);
|
||||
const scope = resolveScope(req, res);
|
||||
if (!scope) return;
|
||||
const { invCond, invArgs } = scope;
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT ipe.nom_deal, p.nom AS plateforme, ipe.mode_detention,
|
||||
r.date_remb, r.distribution_nature, r.net_recu, r.statut
|
||||
FROM remboursements r
|
||||
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.type = 'interets_plateforme'
|
||||
ORDER BY r.date_remb
|
||||
`).all(...invArgs, workspaceId, annee);
|
||||
|
||||
const pertesRows = db.prepare(`
|
||||
SELECT ipe.nom_deal, p.nom AS plateforme, ipe.mode_detention,
|
||||
ip.date_effet AS date_remb, 'perte_definitive' AS distribution_nature,
|
||||
ip.montant_perte AS net_recu, 'perte' AS statut
|
||||
FROM investissement_pe_pertes ip
|
||||
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND substr(ip.date_effet,1,4) = ?
|
||||
`).all(...invArgs, workspaceId, annee);
|
||||
|
||||
const header = ['Deal','Plateforme','Mode de detention','Date','Nature','Montant net','Statut'];
|
||||
const allRows = [...rows, ...pertesRows].sort((a, b) => (a.date_remb || '').localeCompare(b.date_remb || ''));
|
||||
const csv = [
|
||||
header.join(';'),
|
||||
...allRows.map(r => [
|
||||
esc(r.nom_deal), esc(r.plateforme), esc(r.mode_detention),
|
||||
r.date_remb, esc(r.distribution_nature), r.net_recu, r.statut,
|
||||
].join(';')),
|
||||
].join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="fiscalite-pe-${annee}.csv"`);
|
||||
res.send('\uFEFF' + csv);
|
||||
});
|
||||
|
||||
// ── CERFA 2042 / 2561 (IFU) — deals PE en détention directe ────────────────────────────────
|
||||
//
|
||||
// Demande d'Olivier (20/09/26) : reprendre, pour les deals PE en mode de détention 'direct'
|
||||
// (compte-titres, seul régime où un PFU complet s'applique — cf. RegimeCard 'direct' dans
|
||||
// TaxReportPe.jsx), le même principe que les onglets "CERFA 2042" et "CERFA 2561 (IFU)"
|
||||
// crowdlending (Cerfa2042Preview.jsx / Cerfa2561Preview.jsx, taxreport.js::buildCerfa2561) :
|
||||
// un suivi mensuel par plateforme × détenteur, et un mockup des cases de l'IFU réel.
|
||||
//
|
||||
// Différence structurelle importante avec le crowdlending (cf. recherche du 20/09/26 dans
|
||||
// Cerfa2561Preview.jsx, aucune case "cessions de valeurs mobilières" n'y existe) : l'IFU ne
|
||||
// couvre que les "produits de placement à revenu fixe" (case AR → 2TR), jamais les plus-values
|
||||
// de cession. Une distribution PE de nature 'interet_non_deploye' relève donc de cette même
|
||||
// case AR/2TR (jamais KR/2TT, réservée aux prêts de financement participatif — un deal PE est
|
||||
// une part de fonds, pas un prêt) ; une distribution de nature 'plus_value' est HORS PÉRIMÈTRE
|
||||
// IFU (formulaire 2074 → case 3VG du 2042), affichée séparément plutôt que mélangée à la case
|
||||
// 2TR. Une perte définitive PE est, de la même façon, une moins-value de cession (formulaire
|
||||
// 2074 → case 3VH), jamais la case 2TY (spécifique aux prêts participatifs, cf. KS/2TY dans
|
||||
// Cerfa2561Preview.jsx) — cf. commentaire sur cases.case_3VH_direct plus haut dans ce fichier.
|
||||
router.get('/cerfa2561', (req, res) => {
|
||||
const annee = req.query.annee || String(new Date().getFullYear() - 1);
|
||||
const scope = resolveScope(req, res);
|
||||
if (!scope) return;
|
||||
const { invCond, invArgs } = scope;
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
p.id AS plateforme_id,
|
||||
p.nom AS plateforme_nom,
|
||||
inv.id AS investisseur_id,
|
||||
inv.nom AS investisseur_nom,
|
||||
inv.prenom AS investisseur_prenom,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.net_recu ELSE 0 END), 0) AS interets_bruts,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.prelev_sociaux ELSE 0 END), 0) AS prelev_sociaux,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.prelev_forfaitaire ELSE 0 END), 0) AS prelev_forfaitaire,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.net_recu ELSE 0 END), 0) AS plus_values,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.prelev_sociaux ELSE 0 END), 0) AS pv_prelev_sociaux,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.prelev_forfaitaire ELSE 0 END), 0) AS pv_prelev_forfaitaire
|
||||
FROM remboursements r
|
||||
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND ipe.mode_detention = 'direct'
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
GROUP BY p.id, inv.id
|
||||
ORDER BY p.nom, inv.nom
|
||||
`).all(...invArgs, workspaceId, annee);
|
||||
|
||||
const pertesRows = db.prepare(`
|
||||
SELECT p.id AS plateforme_id, ipe.investisseur_id,
|
||||
COALESCE(SUM(ip.montant_perte), 0) AS perte
|
||||
FROM investissement_pe_pertes ip
|
||||
JOIN investissements_pe ipe ON ipe.id = ip.investissement_pe_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND ipe.mode_detention = 'direct'
|
||||
AND substr(ip.date_effet,1,4) = ?
|
||||
GROUP BY p.id, ipe.investisseur_id
|
||||
`).all(...invArgs, workspaceId, annee);
|
||||
const pertesMap = {};
|
||||
for (const p of pertesRows) pertesMap[`${p.plateforme_id}_${p.investisseur_id}`] = p.perte;
|
||||
|
||||
const lignes = rows.map(r => {
|
||||
const key = `${r.plateforme_id}_${r.investisseur_id}`;
|
||||
return {
|
||||
plateforme_id: r.plateforme_id,
|
||||
plateforme_nom: r.plateforme_nom,
|
||||
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),
|
||||
plus_values: round2(r.plus_values),
|
||||
pv_prelev_sociaux: round2(r.pv_prelev_sociaux),
|
||||
pv_prelev_forfaitaire: round2(r.pv_prelev_forfaitaire),
|
||||
// Cases IFU (cf. commentaire au-dessus de la route) :
|
||||
case_2TR: Math.round(r.interets_bruts),
|
||||
case_2BH: r.prelev_sociaux > 0 ? Math.round(r.interets_bruts) : 0,
|
||||
case_2CK: Math.round(r.prelev_forfaitaire),
|
||||
// Hors IFU — reportées séparément (2074), jamais fusionnées à 2TR/2TY.
|
||||
case_3VG: Math.round(r.plus_values),
|
||||
case_3VH: Math.round(pertesMap[key] ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
const moisRows = db.prepare(`
|
||||
SELECT p.id AS plateforme_id, ipe.investisseur_id, substr(r.date_remb,6,2) AS mois,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'interet_non_deploye' THEN r.net_recu ELSE 0 END), 0) AS interets_bruts,
|
||||
COALESCE(SUM(CASE WHEN r.distribution_nature = 'plus_value' THEN r.net_recu ELSE 0 END), 0) AS plus_values
|
||||
FROM remboursements r
|
||||
JOIN investissements_pe ipe ON ipe.id = r.investissement_pe_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond}
|
||||
AND ipe.workspace_id = ?
|
||||
AND ipe.mode_detention = 'direct'
|
||||
AND substr(r.date_remb,1,4) = ?
|
||||
AND r.statut IN ('paye','partiel')
|
||||
AND r.type = 'interets_plateforme'
|
||||
GROUP BY p.id, ipe.investisseur_id, mois
|
||||
`).all(...invArgs, workspaceId, 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), plus_values: round2(m.plus_values) };
|
||||
}
|
||||
|
||||
res.json({ annee, lignes: lignes.map(l => ({ ...l, mois: moisMap[`${l.plateforme_id}_${l.investisseur_id}`] ?? {} })) });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -21,6 +21,7 @@ import simulRouter from './routes/simul.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import dashboardPeRouter from './routes/dashboardPe.js';
|
||||
import taxreportRouter from './routes/taxreport.js';
|
||||
import taxreportPeRouter from './routes/taxreportPe.js';
|
||||
import platefomeTaxRouter from './routes/plateforme-tax.js';
|
||||
import importsRouter from './routes/imports.js';
|
||||
import pfuRouter from './routes/pfu.js';
|
||||
@@ -143,6 +144,7 @@ app.use('/api/simul', requireAuth, simulRouter);
|
||||
app.use('/api/dashboard', requireAuth, dashboardRouter);
|
||||
app.use('/api/dashboard-pe', requireAuth, dashboardPeRouter);
|
||||
app.use('/api/taxreport', requireAuth, taxreportRouter);
|
||||
app.use('/api/taxreport-pe', requireAuth, taxreportPeRouter);
|
||||
app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter);
|
||||
app.use('/api/imports', requireAuth, importsRouter);
|
||||
app.use('/api/pfu', requireAuth, pfuRouter);
|
||||
|
||||
Reference in new issue
Block a user