Mise en place de la notion de perte définitive, ainsi que la révision avec statut Polongation

This commit is contained in:
ocroguennec committed 2026-08-22 16:27:41 +02:00
1 parent 4ad6ea79e8
commit 8e1b82d161
21 files changed
+1069 -184

No files matched your search

+16 -2
View File
@@ -704,7 +704,9 @@ router.get('/interets-par-plateforme', (req, res) => {
i.date_souscription,
i.date_cible,
i.statut,
(SELECT MAX(r.date_remb) FROM remboursements r WHERE r.investissement_id = i.id) AS last_remb_date
(SELECT MAX(r.date_remb) FROM remboursements r WHERE r.investissement_id = i.id) AS last_remb_date,
(SELECT ip.date_effet FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_date_effet,
(SELECT ip.montant_perte FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_montant
FROM investissements i
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
`).all(...invParams);
@@ -792,10 +794,22 @@ router.get('/interets-par-plateforme', (req, res) => {
.reduce((s, r) => s + r.montant, 0);
const capitalRestant = Math.max(0, inv.montant_investi + reinvestsCumul - capitalRembCumul - capitalSimulCumul);
const actif = ['en_cours', 'en_retard', 'procedure'].includes(inv.statut);
// 'prolongation' compte normalement comme capital encours (comme en_retard/procedure
// dont il découle après renégociation) mais n'entre pas dans en_defaut : il est sorti
// temporairement du scope "à risque" tant que la nouvelle échéance n'est pas dépassée.
const actif = ['en_cours', 'en_retard', 'procedure', 'prolongation'].includes(inv.statut);
if (actif) {
capital += capitalRestant;
if (['en_retard', 'procedure'].includes(inv.statut)) enDefaut += capitalRestant;
} else if (inv.statut === 'perte_definitive' && inv.perte_date_effet) {
// Avant la déclaration de perte (fidélité historique) : capital compté normalement,
// comme si le dossier était encore en_retard/procedure ce mois-là. À partir du mois
// de déclaration : capital réduit du montant acté en perte (perte partielle possible).
if (lastDay < inv.perte_date_effet) {
capital += capitalRestant;
} else {
capital += Math.max(0, capitalRestant - (inv.perte_montant || 0));
}
} else {
// Remboursé/clôturé : était-il encore actif ce mois-ci ?
// Priorité : date_cible → dernier remboursement réel → exclure
+236 -15
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimul, generateSimulWithReinvestissements, monthsDiff } from '../utils/schedule.js';
import { generateSimul, generateSimulWithReinvestissements, monthsDiff, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js';
import { checkStatutsRetard } from '../jobs/autoStatut.js';
const router = Router();
@@ -61,20 +61,53 @@ const RevisionSchema = z.object({
message: 'Au moins un changement (nouveau taux ou nouvelle date cible) doit être renseigné',
});
// Déclaration d'une perte définitive de capital sur un investissement en_retard/procedure.
// montant_perte est éditable (perte partielle possible si une partie du capital a déjà
// été recouvrée) — préfilli côté frontend avec le capital restant dû, 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'),
});
function regenererEcheancier(investissementId) {
const inv = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
FROM investissements WHERE id = ?
`).get(investissementId);
if (!inv) return;
const hasReinvest = db.prepare(
'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1'
).get(investissementId);
if (hasReinvest) {
generateSimulWithReinvestissements(db, investissementId);
} else {
const inv = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
FROM investissements WHERE id = ?
`).get(investissementId);
generateSimul(db, inv);
}
// generateSimul()/generateSimulWithReinvestissements() reconstruisent l'échéancier à partir
// du montant_investi ORIGINAL, sans connaître les remboursements anticipés partiels déjà
// effectués — il faut donc systématiquement réappliquer l'ajustement de capital réel après
// toute régénération, sous peine de l'écraser silencieusement :
// - Restructuration (date_debut_simul renseigné, création d'une révision) : seules de
// NOUVELLES échéances futures sont insérées, les échéances déjà honorées ("kept") sont
// préservées telles quelles par generateSimul() lui-même -> un simple adjustSimulForActuals()
// suffit à recalculer ces nouvelles échéances sur la base du capital réellement restant dû.
// - Régénération complète (annulation d'une révision, ou toute autre modif hors
// restructuration) : TOUT l'échéancier est réécrit à neuf -> adjustSimulForActuals() seul
// ne peut PAS reconstruire l'historique (il ne recalcule que ce qui suit le DERNIER
// remboursement connu, jamais une valeur intermédiaire déjà "gelée" par un remboursement
// plus récent) -> il faut rejouer chronologiquement tout l'historique de remboursements
// de capital anticipés via replayAdjustSimulForActuals().
// Bug découvert le 22/08/26 sur le dossier "L'Olympique" : l'annulation d'une révision avait
// effacé l'intégralité de l'ajustement de capital anticipé accumulé sur 18 échéances.
if (inv.date_debut_simul) {
adjustSimulForActuals(db, investissementId);
} else {
replayAdjustSimulForActuals(db, investissementId);
}
}
const Schema = z.object({
@@ -135,6 +168,8 @@ router.get('/', (req, res) => {
(SELECT COALESCE(SUM(r.interets_nets),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_nets_total,
(SELECT COALESCE(SUM(r.net_recu),0) FROM remboursements r WHERE r.investissement_id = i.id) AS net_recu_total,
(SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS reinvestissements_total,
(SELECT COALESCE(SUM(ip.montant_perte),0) FROM investissement_pertes ip WHERE ip.investissement_id = i.id) AS capital_perdu,
(SELECT ip.date_effet FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_date_effet,
i.montant_investi + (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS capital_total
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
@@ -425,6 +460,9 @@ router.get('/:id', (req, res, next) => {
const revisions = db.prepare(
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id);
const pertes = db.prepare(
'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id);
const reinvestissements = db.prepare(
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(req.params.id);
@@ -445,7 +483,7 @@ router.get('/:id', (req, res, next) => {
WHERE is2.investissement_id = ?
ORDER BY is_global DESC, s.nom
`).all(req.params.id);
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, reinvestissements, categories_inv, secteurs_inv });
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, pertes, reinvestissements, categories_inv, secteurs_inv });
} catch (e) { next(e); }
});
@@ -611,6 +649,10 @@ router.delete('/:id/historique/:hid', (req, res, next) => {
// qui n'est qu'un champ d'affichage/contractuel). Donc si nouvelle_date_cible est fournie et
// diffère de la date actuelle, duree_mois doit être recalculé en conséquence, sinon la
// nouvelle date cible ne serait que cosmétique et l'échéancier généré ne la refléterait pas.
//
// Statut "Prolongation" : si la date cible change réellement sur un dossier en_retard/procedure,
// le statut bascule automatiquement à 'prolongation' (sort temporairement du scope "à risque",
// jusqu'à ce que jobs/autoStatut.js le repasse en_retard si la nouvelle échéance est dépassée).
router.post('/:id/revisions', (req, res, next) => {
try {
const body = RevisionSchema.parse(req.body);
@@ -631,27 +673,41 @@ router.post('/:id/revisions', (req, res, next) => {
// Recalcule duree_mois si la date cible change réellement, pour que l'échéancier régénéré
// atteigne effectivement cette nouvelle date (cf. convention date_cible = date_premiere_echeance
// + (duree_mois - 1) mois, utilisée côté frontend pour tous les types de prêt).
const dateChangee = nouvelle_date_cible !== ancienne_date_cible;
let nouveau_duree_mois = ancien_duree_mois;
if (nouvelle_date_cible !== ancienne_date_cible && inv.date_premiere_echeance) {
if (dateChangee && inv.date_premiere_echeance) {
nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1;
}
// Statut "Prolongation" : uniquement quand la date cible change réellement ET que le
// dossier est actuellement en_retard/procedure (renégociation d'un prêt à risque). Un
// changement de taux seul, ou une révision sur un dossier en_cours, ne déclenche jamais
// ce statut — décision confirmée avec Olivier le 22/08/26. Le dossier sort ainsi
// temporairement du scope "à risque" jusqu'à ce que jobs/autoStatut.js le repasse en
// en_retard si la nouvelle date cible est à son tour dépassée sans remboursement.
const RISQUE_STATUTS = ['en_retard', 'procedure'];
const ancien_statut = (dateChangee && RISQUE_STATUTS.includes(inv.statut)) ? inv.statut : null;
const nouveau_statut = ancien_statut ? 'prolongation' : null;
let revisionId;
const tx = db.transaction(() => {
const r = db.prepare(`
INSERT INTO investissement_revisions
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
ancien_duree_mois, nouveau_duree_mois, motif)
VALUES (?,?,?,?,?,?,?,?,?)
ancien_duree_mois, nouveau_duree_mois, ancien_statut, nouveau_statut, motif)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(invId, body.date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
ancien_duree_mois, nouveau_duree_mois, body.motif);
ancien_duree_mois, nouveau_duree_mois, ancien_statut, nouveau_statut, body.motif);
revisionId = r.lastInsertRowid;
// COALESCE(?, statut) : nouveau_statut est NULL quand la révision ne doit pas changer
// le statut (taux seul, dossier déjà sain…) — le statut courant est alors préservé.
db.prepare(`
UPDATE investissements
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, updated_at = datetime('now')
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?,
statut = COALESCE(?, statut), updated_at = datetime('now')
WHERE id = ?
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, invId);
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, nouveau_statut, invId);
regenererEcheancier(invId);
});
@@ -670,7 +726,7 @@ router.delete('/:id/revisions/:rid', (req, res, next) => {
try {
const invId = Number(req.params.id);
const inv = db.prepare(`
SELECT i.id FROM investissements i
SELECT i.id, i.statut FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, req.user.id);
@@ -683,17 +739,25 @@ router.delete('/:id/revisions/:rid', (req, res, next) => {
throw new HttpError(400, 'Seule la révision la plus récente peut être supprimée');
}
// Ne restaure le statut que si rien ne l'a fait évoluer depuis cette révision (ex : le
// job autoStatut a déjà repassé le dossier en_retard entre-temps) — sinon on écraserait
// un état plus récent et légitime avec l'ancien statut de la révision annulée.
const statutRestore = (derniere.nouveau_statut && inv.statut === derniere.nouveau_statut)
? derniere.ancien_statut
: null;
const tx = db.transaction(() => {
db.prepare(`
UPDATE investissements
SET taux_interet = ?, date_cible = ?, duree_mois = ?,
statut = COALESCE(?, statut),
date_debut_simul = (
SELECT date_effet FROM investissement_revisions
WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1
),
updated_at = datetime('now')
WHERE id = ?
`).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, invId, derniere.id, invId);
`).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, statutRestore, invId, derniere.id, invId);
db.prepare('DELETE FROM investissement_revisions WHERE id = ?').run(derniere.id);
@@ -705,6 +769,163 @@ router.delete('/:id/revisions/:rid', (req, res, next) => {
} catch (e) { next(e); }
});
// PUT /api/investissements/:id/revisions/:rid
// Corrige la révision la plus récente en place (même ligne d'historique conservée), plutôt que
// de la supprimer puis d'en recréer une — évite un aller-retour et garde une trace stable dans
// l'historique. ancien_taux/ancienne_date_cible/ancien_duree_mois restent figés sur l'état
// D'AVANT CETTE RÉVISION (jamais modifiés par une correction) ; seuls les nouveau_* et le motif
// sont mis à jour. Même contrainte que la suppression : seule la révision la plus récente peut
// être corrigée, pour éviter un état incohérent si une révision intermédiaire était éditée.
router.put('/:id/revisions/:rid', (req, res, next) => {
try {
const body = RevisionSchema.parse(req.body);
const invId = Number(req.params.id);
const inv = db.prepare(`
SELECT i.id, i.statut, i.date_premiere_echeance FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, req.user.id);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const derniere = db.prepare(
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id DESC LIMIT 1'
).get(invId);
if (!derniere || derniere.id !== Number(req.params.rid)) {
throw new HttpError(400, 'Seule la révision la plus récente peut être corrigée');
}
// Baseline inchangée : l'état d'AVANT cette révision (jamais retouché par une correction).
const ancien_taux = derniere.ancien_taux;
const ancienne_date_cible = derniere.ancienne_date_cible;
const ancien_duree_mois = derniere.ancien_duree_mois;
const nouveau_taux = body.nouveau_taux ?? ancien_taux;
const nouvelle_date_cible = body.nouvelle_date_cible ?? ancienne_date_cible;
const dateChangee = nouvelle_date_cible !== ancienne_date_cible;
let nouveau_duree_mois = ancien_duree_mois;
if (dateChangee && inv.date_premiere_echeance) {
nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1;
}
// Statut : on rejoue le même raisonnement que POST/DELETE combinés — d'abord retrouver le
// statut d'avant l'effet de cette révision (sauf si autre chose l'a fait évoluer entre
// temps, ex. autoStatut), puis réévaluer si la version CORRIGÉE doit déclencher Prolongation.
const RISQUE_STATUTS = ['en_retard', 'procedure'];
const statutBase = (derniere.nouveau_statut && inv.statut === derniere.nouveau_statut)
? derniere.ancien_statut
: inv.statut;
const ancien_statut = (dateChangee && RISQUE_STATUTS.includes(statutBase)) ? statutBase : null;
const nouveau_statut = ancien_statut ? 'prolongation' : null;
const statutFinal = nouveau_statut ?? statutBase;
const tx = db.transaction(() => {
db.prepare(`
UPDATE investissement_revisions
SET date_effet = ?, nouveau_taux = ?, nouvelle_date_cible = ?, nouveau_duree_mois = ?,
ancien_statut = ?, nouveau_statut = ?, motif = ?
WHERE id = ?
`).run(body.date_effet, nouveau_taux, nouvelle_date_cible, nouveau_duree_mois,
ancien_statut, nouveau_statut, body.motif, derniere.id);
db.prepare(`
UPDATE investissements
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?,
statut = ?, updated_at = datetime('now')
WHERE id = ?
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, statutFinal, invId);
regenererEcheancier(invId);
});
tx();
const revision = db.prepare('SELECT * FROM investissement_revisions WHERE id = ?').get(derniere.id);
res.json(revision);
} catch (e) { next(e); }
});
// POST /api/investissements/:id/perte-definitive
// Déclare qu'un investissement en_retard/procedure est définitivement perdu (irrécouvrable).
// Le montant est renseigné explicitement (perte totale ou partielle si récupération partielle)
// plutôt que recalculé côté serveur, pour laisser l'utilisateur ajuster au moment du constat.
// Trace l'ancien statut dans investissement_pertes pour permettre une annulation propre.
router.post('/:id/perte-definitive', (req, res, next) => {
try {
const body = PerteSchema.parse(req.body);
const invId = Number(req.params.id);
const inv = db.prepare(`
SELECT i.* FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, req.user.id);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
if (!['en_retard', 'procedure'].includes(inv.statut)) {
throw new HttpError(400, 'Seuls les dossiers "En retard" ou "Procédure" peuvent être déclarés en perte définitive');
}
let perteId;
const tx = db.transaction(() => {
const r = db.prepare(`
INSERT INTO investissement_pertes (investissement_id, date_effet, montant_perte, ancien_statut, motif)
VALUES (?,?,?,?,?)
`).run(invId, body.date_effet, body.montant_perte, inv.statut, body.motif);
perteId = r.lastInsertRowid;
db.prepare(`
UPDATE investissements SET statut = 'perte_definitive', updated_at = datetime('now') WHERE id = ?
`).run(invId);
recordHistory(invId, {
type_evenement: 'perte_definitive',
changements: [{ champ: 'statut', label: 'Statut', ancienne_valeur: inv.statut, nouvelle_valeur: 'perte_definitive' }],
notes: `Perte définitive déclarée (${body.montant_perte.toFixed(2)} €) : ${body.motif}`,
});
});
tx();
const perte = db.prepare('SELECT * FROM investissement_pertes WHERE id = ?').get(perteId);
res.status(201).json(perte);
} catch (e) { next(e); }
});
// DELETE /api/investissements/:id/perte-definitive
// Annule la déclaration de perte la plus récente et restaure le statut précédent
// (en_retard ou procédure) — le dossier redevient actif au sens fiscal/KPI.
router.delete('/:id/perte-definitive', (req, res, next) => {
try {
const invId = Number(req.params.id);
const inv = db.prepare(`
SELECT i.* FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, req.user.id);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
if (inv.statut !== 'perte_definitive') {
throw new HttpError(400, "Ce dossier n'est pas déclaré en perte définitive");
}
const derniere = db.prepare(
'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id DESC LIMIT 1'
).get(invId);
const statutRestaure = derniere?.ancien_statut || 'en_retard';
const tx = db.transaction(() => {
if (derniere) db.prepare('DELETE FROM investissement_pertes WHERE id = ?').run(derniere.id);
db.prepare(`
UPDATE investissements SET statut = ?, updated_at = datetime('now') WHERE id = ?
`).run(statutRestaure, invId);
recordHistory(invId, {
type_evenement: 'annulation_perte_definitive',
changements: [{ champ: 'statut', label: 'Statut', ancienne_valeur: 'perte_definitive', nouvelle_valeur: statutRestaure }],
notes: 'Annulation de la déclaration de perte définitive',
});
});
tx();
res.status(204).end();
} catch (e) { next(e); }
});
// PUT /api/investissements/:id/auto-reinvest { active: true|false }
router.put('/:id/auto-reinvest', (req, res, next) => {
try {
+8 -1
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { buildSchedule, adjustSimulForActuals } from '../utils/schedule.js';
import { buildSchedule, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js';
const router = Router();
@@ -149,7 +149,14 @@ router.post('/recalculate', (req, res, next) => {
const { investissement_id } = req.body;
if (!investissement_id) throw new HttpError(400, 'investissement_id required');
assertOwnedInvestissement(Number(investissement_id), req.user.id);
// adjustSimulForActuals() gère aussi la régénération complète quand aucun capital n'a
// encore été remboursé ; replayAdjustSimulForActuals() rejoue ensuite tout l'historique des
// remboursements de capital anticipés pour reconstruire fidèlement les valeurs
// intermédiaires qu'un simple appel unique ne peut pas restaurer (cf. bug "L'Olympique" du
// 22/08/26, feedback_generateSimul_restructuration_dates.md) — sans effet si l'échéancier
// était déjà cohérent.
adjustSimulForActuals(db, Number(investissement_id));
replayAdjustSimulForActuals(db, Number(investissement_id));
const rows = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance'
).all(investissement_id);
+14 -13
View File
@@ -59,19 +59,21 @@ router.get('/', (req, res) => {
recap.net_recu += recapInteretsPlateforme.net_recu;
recap.nb_remboursements += recapInteretsPlateforme.nb_remboursements;
// Pertes en capital : uniquement les dossiers explicitement déclarés en perte
// définitive (statut='perte_definitive'), avec le montant réellement acté dans
// investissement_pertes — pas les simples retards, ni les clôtures normales.
const pertes = db.prepare(`
SELECT i.id, i.nom_projet, p.nom AS plateforme_nom,
i.montant_investi,
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) AS capital_rembourse,
(i.montant_investi -
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0)
) AS perte_capital,
i.statut, i.updated_at
ip.montant_perte AS perte_capital,
i.statut, ip.date_effet
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investissement_pertes ip ON ip.investissement_id = i.id
WHERE ${invCond}
AND i.statut IN ('en_retard','cloture')
AND substr(i.updated_at,1,4) = ?
AND i.statut = 'perte_definitive'
AND substr(ip.date_effet,1,4) = ?
`).all(invArg, annee);
const pertesTotales = pertes.reduce((s, p) => s + Math.max(0, p.perte_capital), 0);
@@ -270,20 +272,19 @@ function buildCerfa2561(annee, invCond, invCondBonus2561, invArg) {
const rowsMerged = Object.values(rowsMap).sort((a, b) =>
a.plateforme_nom.localeCompare(b.plateforme_nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? ''));
// Pertes en capital par plateforme × investisseur
// Pertes en capital par plateforme × investisseur — uniquement les dossiers
// explicitement déclarés en perte définitive (voir note plus haut dans la route '/').
const pertesRows = db.prepare(`
SELECT
p.id AS plateforme_id,
i.investisseur_id,
COALESCE(SUM(
i.montant_investi -
COALESCE((SELECT SUM(r2.capital) FROM remboursements r2 WHERE r2.investissement_id = i.id),0)
), 0) AS perte_capital
COALESCE(SUM(ip.montant_perte), 0) AS perte_capital
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investissement_pertes ip ON ip.investissement_id = i.id
WHERE ${invCond}
AND i.statut IN ('en_retard','cloture')
AND substr(i.updated_at,1,4) = ?
AND i.statut = 'perte_definitive'
AND substr(ip.date_effet,1,4) = ?
GROUP BY p.id, i.investisseur_id
`).all(invArg, annee);
+1 -1
View File
@@ -21,7 +21,7 @@ const LIST_COLUMNS = `
* parameters:
* - in: query
* name: statut
* schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture] }
* schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture, perte_definitive, prolongation] }
* responses:
* 200: { description: Liste des investissements }
*/
+6 -2
View File
@@ -88,13 +88,17 @@ router.get('/', (req, res) => {
// ── 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 / 2TY : cumul direct des lignes 2561 françaises.
// - 2TT : cumul direct des lignes 2561 françaises. 2TY (pertes en capital,
// statut 'perte_definitive' uniquement) : cumul sur toutes les lignes 2561,
// FR et étranger confondues — une perte définitive n'est pas spécifique à la France.
// - 2TR : cumul des lignes 2561 françaises + brut étranger (toujours reporté, que le
// PFO s'applique ou non — seule la déclaration annuelle 2047 est en jeu ici).
// - 2BH / 2CK : la part étrangère n'est ajoutée que si pfo_assujetti est vrai, sinon
// ces cases ne sont pas censées être alimentées (cf. mémoire projet — la case 2CK
// ne s'alimente que si la déclaration 2778-SD est active).
const frLignes = cerfa2561.lignes.filter(l => l.domiciliation === 'FR');
// case_2TY (pertes en capital) n'est pas spécifique FR/étranger — voir TaxReport.jsx
const allLignes = cerfa2561.lignes;
const platEtr = cerfa2778_mensuel?.plateformes ?? [];
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
const totalBA = platEtr.reduce((s, p) => s + etrBA(p), 0);
@@ -105,7 +109,7 @@ router.get('/', (req, res) => {
case_2TR: sumBy(frLignes, 'case_2TR') + Math.round(totalBA),
case_2BH: sumBy(frLignes, 'case_2BH') + (pfoAssujetti ? Math.round(totalBA) : 0),
case_2CK: sumBy(frLignes, 'case_2CK') + (pfoAssujetti ? Math.round(totalBA * PFO_RATE) : 0),
case_2TY: sumBy(frLignes, 'case_2TY'),
case_2TY: sumBy(allLignes, 'case_2TY'),
};
res.json({