This commit is contained in:
ocroguennec committed 2026-09-26 11:15:26 +02:00
1 parent 14cf4e5a19
commit 26197fc411
15 files changed
+1776 -29

No files matched your search

+91 -1
View File
@@ -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) => {