Mise en place de la notion de perte définitive, ainsi que la révision avec statut Polongation
This commit is contained in:
1 parent
4ad6ea79e8
commit
8e1b82d161
21 files changed
+1069
-184
No files matched your search
@@ -464,6 +464,21 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_revisions_inv ON investissement_revision
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : traçage du statut lors d'une révision (fonctionnalité Prolongation) ──
|
||||
// Quand une révision change la date cible d'un dossier en_retard/procedure, le statut
|
||||
// bascule automatiquement à 'prolongation' (cf. routes/investissements.js). On trace
|
||||
// l'ancien et le nouveau statut ici, comme ancien_taux/nouveau_taux, pour permettre un
|
||||
// rollback fidèle (DELETE /revisions/:rid restaure ancien_statut).
|
||||
{
|
||||
const revColsStatut = db.prepare('PRAGMA table_info(investissement_revisions)').all().map(c => c.name);
|
||||
if (!revColsStatut.includes('ancien_statut')) {
|
||||
db.exec('ALTER TABLE investissement_revisions ADD COLUMN ancien_statut TEXT');
|
||||
}
|
||||
if (!revColsStatut.includes('nouveau_statut')) {
|
||||
db.exec('ALTER TABLE investissement_revisions ADD COLUMN nouveau_statut TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : rôle utilisateur ─────────────────────────────────────────
|
||||
{
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
@@ -2381,4 +2396,140 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : ajout du statut 'perte_definitive' ──────────────────────
|
||||
// Nouvelle valeur d'enum pour déclarer explicitement qu'un capital est
|
||||
// définitivement perdu (irrécouvrable), distincte de 'en_retard'/'procedure'
|
||||
// qui restent potentiellement recouvrables. SQLite ne permet pas d'altérer un
|
||||
// CHECK constraint : on recrée la table avec le nouveau CHECK, même stratégie
|
||||
// que la migration 'defaut' → 'en_retard' plus haut. Placé en toute fin de
|
||||
// fichier (après toutes les migrations ADD COLUMN de la table investissements)
|
||||
// pour que PRAGMA table_info reflète bien la totalité des colonnes, y compris
|
||||
// sur une base fraîchement créée depuis schema.sql.
|
||||
{
|
||||
const schemaInvPerte = db.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'"
|
||||
).get()?.sql ?? '';
|
||||
|
||||
if (schemaInvPerte && !schemaInvPerte.includes("'perte_definitive'")) {
|
||||
const tempName = '__repair_investissements_perte';
|
||||
|
||||
// Le nom de table peut être stocké avec ou sans guillemets dans
|
||||
// sqlite_master.sql selon qu'une précédente migration (ex. 'defaut' →
|
||||
// 'en_retard') a déjà renommé la table via ALTER TABLE ... RENAME TO
|
||||
// (SQLite conserve alors le nom entre guillemets dans le DDL stocké) —
|
||||
// le regex doit couvrir les deux formes, sinon le remplacement échoue
|
||||
// silencieusement et fixedDdl retente de créer "investissements" alors
|
||||
// qu'elle existe déjà (SqliteError: table "investissements" already exists).
|
||||
const fixedDdl = schemaInvPerte
|
||||
.replace(/CREATE TABLE\s+"?investissements"?/, `CREATE TABLE "${tempName}"`)
|
||||
.replace(",'cloture'))", ",'cloture','perte_definitive'))");
|
||||
|
||||
if (fixedDdl.includes(`"${tempName}"`) && fixedDdl.includes("'perte_definitive'")) {
|
||||
const colDefs = db.prepare('PRAGMA table_info(investissements)').all();
|
||||
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
|
||||
|
||||
const idxs = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL"
|
||||
).all();
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
|
||||
db.exec(fixedDdl);
|
||||
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM investissements`);
|
||||
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||
db.exec('DROP TABLE investissements');
|
||||
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
|
||||
db.exec('PRAGMA legacy_alter_table = ON');
|
||||
db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`);
|
||||
db.exec('PRAGMA legacy_alter_table = OFF');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)');
|
||||
|
||||
console.log("[DB] investissements: statut 'perte_definitive' ajouté au CHECK constraint");
|
||||
} else {
|
||||
console.error("[DB] Migration perte_definitive : nom de table ou motif ',cloture))' introuvable dans le DDL — migration ignorée, à vérifier manuellement");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : table investissement_pertes ──────────────────────────────
|
||||
// Trace chaque déclaration de perte définitive : date de constat, montant
|
||||
// perdu (préfilli côté frontend avec le capital restant dû mais éditable pour
|
||||
// une perte partielle si une partie du capital a déjà été recouvrée) et motif
|
||||
// obligatoire. Distincte de investissement_historique (piste d'audit générique
|
||||
// auto-détectée) : ici on conserve la donnée structurée exploitée par le
|
||||
// calcul fiscal (case 2TY) et par les KPI de capital investi/restant dû.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS investissement_pertes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
investissement_id INTEGER NOT NULL REFERENCES investissements(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_pertes_inv ON investissement_pertes(investissement_id)');
|
||||
|
||||
// ── Migration : ajout du statut 'prolongation' ──────────────────────────
|
||||
// Nouvelle valeur d'enum posée automatiquement par POST /revisions quand une révision
|
||||
// des conditions change la date cible d'un dossier en_retard/procedure (renégociation
|
||||
// avec échéancier modifié) : le dossier sort temporairement du scope "à risque" jusqu'à
|
||||
// ce que la nouvelle date cible soit à son tour dépassée (cf. jobs/autoStatut.js qui le
|
||||
// repasse alors en_retard). Même stratégie de recréation de table que 'perte_definitive'
|
||||
// ci-dessus (SQLite ne permet pas d'altérer un CHECK constraint). Placé après cette
|
||||
// dernière migration pour bénéficier du même schemaInv déjà à jour (CHECK contenant
|
||||
// déjà 'perte_definitive' à ce stade, sur une base existante comme sur une base neuve).
|
||||
{
|
||||
const schemaInvProlong = db.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'"
|
||||
).get()?.sql ?? '';
|
||||
|
||||
if (schemaInvProlong && !schemaInvProlong.includes("'prolongation'")) {
|
||||
const tempName = '__repair_investissements_prolongation';
|
||||
|
||||
// Cf. migration perte_definitive plus haut : le nom de table peut être stocké
|
||||
// avec ou sans guillemets dans sqlite_master.sql selon l'historique des ALTER
|
||||
// TABLE ... RENAME TO déjà exécutés — le regex doit couvrir les deux formes.
|
||||
const fixedDdl = schemaInvProlong
|
||||
.replace(/CREATE TABLE\s+"?investissements"?/, `CREATE TABLE "${tempName}"`)
|
||||
.replace(",'perte_definitive'))", ",'perte_definitive','prolongation'))");
|
||||
|
||||
if (fixedDdl.includes(`"${tempName}"`) && fixedDdl.includes("'prolongation'")) {
|
||||
const colDefs = db.prepare('PRAGMA table_info(investissements)').all();
|
||||
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
|
||||
|
||||
const idxs = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL"
|
||||
).all();
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
|
||||
db.exec(fixedDdl);
|
||||
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM investissements`);
|
||||
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||
db.exec('DROP TABLE investissements');
|
||||
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
|
||||
db.exec('PRAGMA legacy_alter_table = ON');
|
||||
db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`);
|
||||
db.exec('PRAGMA legacy_alter_table = OFF');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)');
|
||||
|
||||
console.log("[DB] investissements: statut 'prolongation' ajouté au CHECK constraint");
|
||||
} else {
|
||||
console.error("[DB] Migration prolongation : nom de table ou motif \",'perte_definitive'))\" introuvable dans le DDL — migration ignorée, à vérifier manuellement");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
@@ -87,7 +87,7 @@ CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- INVESTISSEMENTS (CF Investissements - liste des projets souscrits)
|
||||
-- statut: 'en_cours' | 'rembourse' | 'en_retard' | 'procedure' | 'cloture'
|
||||
-- statut: 'en_cours' | 'rembourse' | 'en_retard' | 'procedure' | 'cloture' | 'perte_definitive' | 'prolongation'
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS investissements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -104,7 +104,7 @@ CREATE TABLE IF NOT EXISTS investissements (
|
||||
type_remb TEXT, -- 'in_fine' | 'amortissable' | 'differe'
|
||||
freq_interets TEXT NOT NULL DEFAULT 'mensuel', -- 'mensuel' | 'trimestriel' | 'in_fine'
|
||||
statut TEXT NOT NULL DEFAULT 'en_cours'
|
||||
CHECK(statut IN ('en_cours','rembourse','en_retard','procedure','cloture')),
|
||||
CHECK(statut IN ('en_cours','rembourse','en_retard','procedure','cloture','perte_definitive','prolongation')),
|
||||
reference TEXT, -- ID projet sur la plateforme
|
||||
source TEXT NOT NULL DEFAULT 'manuel',
|
||||
notes TEXT,
|
||||
|
||||
@@ -28,9 +28,14 @@ function notifyUser(userId, { type, title, body, link }) {
|
||||
|
||||
/**
|
||||
* Passe automatiquement au statut "en_retard" les investissements dont :
|
||||
* - le statut est actuellement "en_cours"
|
||||
* - le statut est actuellement "en_cours" OU "prolongation"
|
||||
* - la date_cible est renseignée et strictement antérieure à aujourd'hui
|
||||
*
|
||||
* Le cas "prolongation" couvre un dossier dont la date cible avait été repoussée via une
|
||||
* révision des conditions (renégociation suite à un retard) et qui retombe en retard parce
|
||||
* que la nouvelle échéance négociée est à son tour dépassée sans remboursement — cf.
|
||||
* POST /investissements/:id/revisions qui pose le statut 'prolongation'.
|
||||
*
|
||||
* Chaque passage est tracé dans investissement_historique avec le
|
||||
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité, et
|
||||
* génère une notification utilisateur.
|
||||
@@ -39,10 +44,10 @@ function notifyUser(userId, { type, title, body, link }) {
|
||||
*/
|
||||
function checkPassagesEnRetard() {
|
||||
const candidats = db.prepare(`
|
||||
SELECT i.id, i.nom_projet, i.date_cible, inv.user_id
|
||||
SELECT i.id, i.nom_projet, i.date_cible, i.statut, inv.user_id
|
||||
FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE i.statut = 'en_cours'
|
||||
WHERE i.statut IN ('en_cours', 'prolongation')
|
||||
AND i.date_cible IS NOT NULL
|
||||
AND i.date_cible < date('now')
|
||||
`).all();
|
||||
@@ -69,15 +74,19 @@ function checkPassagesEnRetard() {
|
||||
JSON.stringify([{
|
||||
champ: 'statut',
|
||||
label: 'Statut',
|
||||
ancienne_valeur: 'en_cours',
|
||||
ancienne_valeur: inv.statut,
|
||||
nouvelle_valeur: 'en_retard',
|
||||
}]),
|
||||
`Passage automatique : date cible (${inv.date_cible}) dépassée`
|
||||
inv.statut === 'prolongation'
|
||||
? `Passage automatique : la nouvelle échéance négociée (${inv.date_cible}) est à son tour dépassée`
|
||||
: `Passage automatique : date cible (${inv.date_cible}) dépassée`
|
||||
);
|
||||
notifyUser(inv.user_id, {
|
||||
type: 'warning',
|
||||
title: `Prêt en retard : "${inv.nom_projet}"`,
|
||||
body: `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`,
|
||||
body: inv.statut === 'prolongation'
|
||||
? `La date cible négociée (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt repasse automatiquement au statut "En retard".`
|
||||
: `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`,
|
||||
link: `/investissements/${inv.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
+185
-78
@@ -158,7 +158,78 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0'
|
||||
).get(investissementId);
|
||||
|
||||
// Capital effectivement investi jusqu'à la date du dernier remboursement
|
||||
applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital);
|
||||
|
||||
// Ajustement de la première période partielle (mois incomplet)
|
||||
adjustFirstPartialPeriod(db, investissementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoue chronologiquement l'intégralité de l'historique des remboursements de capital
|
||||
* anticipés pour reconstruire l'ajustement progressif de l'échéancier.
|
||||
*
|
||||
* Nécessaire après une régénération COMPLÈTE (generateSimul()/generateSimulWithReinvestissements()
|
||||
* en mode standard, qui réécrit TOUT l'échéancier à neuf à partir du montant_investi d'origine,
|
||||
* sans connaître les remboursements anticipés déjà effectués) — contrairement à un simple appel
|
||||
* à adjustSimulForActuals(), qui ne recalcule que les échéances postérieures au DERNIER
|
||||
* remboursement connu et ne peut donc pas reconstituer une valeur intermédiaire déjà "gelée"
|
||||
* par un remboursement plus récent (ex. la dernière échéance d'un prêt in fine, soldée
|
||||
* partiellement : son capital_prevu ajusté a été fixé par le remboursement anticipé PRÉCÉDENT,
|
||||
* pas par celui qui la solde, donc un unique appel après régénération ne la retoucherait
|
||||
* jamais — il faut rejouer les remboursements dans l'ordre pour reconstruire cette valeur).
|
||||
*
|
||||
* Utilisé par regenererEcheancier() (routes/investissements.js) après une régénération
|
||||
* complète (ex. annulation d'une révision) — jamais après une restructuration partielle, où
|
||||
* les échéances déjà honorées sont préservées telles quelles par generateSimul() lui-même.
|
||||
*
|
||||
* Bug découvert le 22/08/26 sur le dossier "L'Olympique" : l'annulation d'une révision a
|
||||
* effacé tout l'historique d'ajustement de capital anticipé accumulé sur 18 échéances,
|
||||
* l'échéancier retombant sur les valeurs naïves d'origine (intérêts et capital final
|
||||
* recalculés sur le montant investi total, comme si aucun remboursement anticipé n'avait
|
||||
* jamais eu lieu).
|
||||
*/
|
||||
export function replayAdjustSimulForActuals(db, 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 || inv.taux_interet == null || !inv.duree_mois) return;
|
||||
|
||||
const reinvests = db.prepare(
|
||||
'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
|
||||
).all(investissementId);
|
||||
|
||||
// Chaque date distincte où du capital a été remboursé, dans l'ordre chronologique — ce sont
|
||||
// les points où adjustSimulForActuals() aurait été appelé en conditions réelles.
|
||||
const dates = db.prepare(`
|
||||
SELECT DISTINCT date_remb FROM remboursements
|
||||
WHERE investissement_id = ? AND capital > 0
|
||||
ORDER BY date_remb ASC
|
||||
`).all(investissementId).map(r => r.date_remb);
|
||||
|
||||
if (!dates.length) return; // aucun remboursement de capital : l'échéancier naïf est déjà correct
|
||||
|
||||
for (const d of dates) {
|
||||
const { total_capital } = db.prepare(
|
||||
'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ? AND date_remb <= ?'
|
||||
).get(investissementId, d);
|
||||
applyCapitalAdjustment(db, inv, reinvests, d, total_capital);
|
||||
}
|
||||
|
||||
adjustFirstPartialPeriod(db, investissementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cœur du recalcul des échéances futures pour un capital remboursé et une date de référence
|
||||
* donnés — factorisé entre adjustSimulForActuals() (un seul point dans le temps : le dernier
|
||||
* remboursement connu) et replayAdjustSimulForActuals() (rejoue ce même calcul à CHAQUE point
|
||||
* historique, dans l'ordre, pour reconstruire les valeurs intermédiaires "gelées" par des
|
||||
* remboursements plus récents).
|
||||
*/
|
||||
function applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital) {
|
||||
// Capital effectivement investi jusqu'à la date de référence
|
||||
// (initial + réinvestissements survenus avant ou à cette date)
|
||||
const capitalAtLastDate = round2(
|
||||
inv.montant_investi +
|
||||
@@ -167,25 +238,25 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
|
||||
const remainingCapital = round2(capitalAtLastDate - total_capital);
|
||||
|
||||
// Entrées de simulation à recalculer (strictement après la date du dernier remb capital)
|
||||
// Entrées de simulation à recalculer (strictement après la date de référence)
|
||||
const futureEntries = db.prepare(`
|
||||
SELECT * FROM simul_remboursements
|
||||
WHERE investissement_id = ? AND date_prevue > ?
|
||||
ORDER BY numero_echeance
|
||||
`).all(investissementId, last_date);
|
||||
`).all(inv.id, last_date);
|
||||
|
||||
if (futureEntries.length === 0) return;
|
||||
|
||||
// Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante
|
||||
if (remainingCapital <= 0) {
|
||||
// Entrée simul qui couvre la période du dernier remboursement capital
|
||||
// Entrée simul qui couvre la période de la date de référence
|
||||
const lastPaidEntry = db.prepare(`
|
||||
SELECT id, interets_prevus, capital_prevu
|
||||
FROM simul_remboursements
|
||||
WHERE investissement_id = ? AND date_prevue <= ?
|
||||
ORDER BY date_prevue DESC
|
||||
LIMIT 1
|
||||
`).get(investissementId, last_date);
|
||||
`).get(inv.id, last_date);
|
||||
|
||||
db.transaction(() => {
|
||||
// Supprimer les échéances futures devenues caduques
|
||||
@@ -213,7 +284,7 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
const nFuture = futureEntries.length;
|
||||
const type = inv.type_remb || 'in_fine';
|
||||
|
||||
// Réinvestissements encore à venir (après la date du dernier remboursement)
|
||||
// Réinvestissements encore à venir (après la date de référence)
|
||||
const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date);
|
||||
|
||||
const updates = [];
|
||||
@@ -278,9 +349,6 @@ export function adjustSimulForActuals(db, investissementId) {
|
||||
`);
|
||||
for (const u of updates) stmt.run(u.capital, u.interets, u.total, u.id);
|
||||
})();
|
||||
|
||||
// Ajustement de la première période partielle (mois incomplet)
|
||||
adjustFirstPartialPeriod(db, investissementId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,20 +452,49 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
|
||||
if (inv.taux_interet == null || !inv.duree_mois) return;
|
||||
|
||||
const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription;
|
||||
if (!startDate) return;
|
||||
|
||||
const finDeMois = !!inv.echeance_fin_de_mois;
|
||||
const type = inv.type_remb || 'in_fine';
|
||||
const freq = inv.freq_interets || 'mensuel';
|
||||
const step = freq === 'trimestriel' ? 3 : 1;
|
||||
const rPer = (inv.taux_interet / 100 / 12) * step;
|
||||
const isRestructuration = !!inv.date_debut_simul;
|
||||
|
||||
// Durée effective (tient compte d'une éventuelle restructuration)
|
||||
let effectiveDuree = inv.duree_mois;
|
||||
if (inv.date_debut_simul && inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) {
|
||||
const elapsed = monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul);
|
||||
effectiveDuree = Math.max(1, inv.duree_mois - elapsed);
|
||||
// En mode restructuration, il faut connaître les échéances déjà honorées AVANT de
|
||||
// calculer la date de départ de la suite de l'échéancier (cf. generateSimul() pour le
|
||||
// détail du raisonnement) — simple lecture, peut se faire hors transaction.
|
||||
let keptEntries = [];
|
||||
if (isRestructuration) {
|
||||
keptEntries = db.prepare(`
|
||||
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
|
||||
WHERE sr.investissement_id = ?
|
||||
AND sr.date_prevue < ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM remboursements r
|
||||
WHERE r.investissement_id = sr.investissement_id
|
||||
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
|
||||
)
|
||||
ORDER BY sr.numero_echeance
|
||||
`).all(investissementId, inv.date_debut_simul);
|
||||
}
|
||||
|
||||
let startDate, effectiveDuree = inv.duree_mois, elapsedMonths = 0;
|
||||
|
||||
if (isRestructuration) {
|
||||
// Même logique que generateSimul() : numérotation absolue anti-collision (jamais en
|
||||
// dessous du plus haut numero_echeance déjà conservé) et date de départ qui continue
|
||||
// la cadence mensuelle d'origine plutôt que de repartir littéralement de date_debut_simul.
|
||||
const dateBasedElapsed = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance)
|
||||
? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul)
|
||||
: keptEntries.length;
|
||||
const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0);
|
||||
elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero);
|
||||
effectiveDuree = Math.max(1, inv.duree_mois - elapsedMonths);
|
||||
|
||||
const cadenceBase = inv.date_premiere_echeance || inv.date_debut_simul;
|
||||
startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths);
|
||||
} else {
|
||||
startDate = inv.date_premiere_echeance || inv.date_souscription;
|
||||
if (!startDate) return;
|
||||
}
|
||||
|
||||
// Calendrier de base pour obtenir les dates de chaque échéance
|
||||
@@ -463,24 +560,10 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
if (inv.date_debut_simul) {
|
||||
if (isRestructuration) {
|
||||
// ── Mode restructuration ──────────────────────────────────────────────
|
||||
// Même logique que generateSimul() : conserver les échéances déjà payées avant
|
||||
// la date de restructuration, supprimer le reste, et renuméroter à partir du
|
||||
// nombre de mois réellement écoulés (pas de repartir à 1, qui ferait disparaître
|
||||
// les échéances passées — déjà payées — de la table des projections).
|
||||
const keptEntries = db.prepare(`
|
||||
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
|
||||
WHERE sr.investissement_id = ?
|
||||
AND sr.date_prevue < ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM remboursements r
|
||||
WHERE r.investissement_id = sr.investissement_id
|
||||
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
|
||||
)
|
||||
ORDER BY sr.numero_echeance
|
||||
`).all(investissementId, inv.date_debut_simul);
|
||||
|
||||
// keptEntries/elapsedMonths déjà calculés plus haut (nécessaire pour la date de
|
||||
// départ du calendrier de base) : on les réutilise tels quels ici.
|
||||
if (keptEntries.length > 0) {
|
||||
db.prepare(
|
||||
`DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})`
|
||||
@@ -489,10 +572,6 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
|
||||
}
|
||||
|
||||
const elapsedMonths = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance)
|
||||
? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul)
|
||||
: keptEntries.length;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO simul_remboursements
|
||||
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
|
||||
@@ -520,9 +599,13 @@ export function generateSimulWithReinvestissements(db, investissementId) {
|
||||
* Génère (ou régénère) le tableau d'amortissement d'un investissement dans la DB.
|
||||
* Ne fait rien si taux_interet ou duree_mois est absent.
|
||||
*
|
||||
* Si date_debut_simul est renseigné (restructuration de prêt), la simulation
|
||||
* démarre à cette date et la durée effective est réduite du nombre de mois déjà
|
||||
* écoulés depuis date_premiere_echeance, afin de ne pas allonger artificiellement le prêt.
|
||||
* Si date_debut_simul est renseigné (restructuration de prêt suite à une révision des
|
||||
* conditions), la durée effective est réduite du nombre de mois déjà écoulés depuis
|
||||
* date_premiere_echeance, afin de ne pas allonger artificiellement le prêt — et la suite
|
||||
* de l'échéancier continue la cadence mensuelle d'origine (même jour du mois que
|
||||
* date_premiere_echeance) à partir de la première échéance pas encore écoulée. date_debut_simul
|
||||
* n'est PAS utilisé comme date de la première nouvelle échéance : ce n'est que la date
|
||||
* d'effet administrative de la révision, qui peut tomber n'importe quel jour du mois.
|
||||
*/
|
||||
export function generateSimul(db, inv) {
|
||||
const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||
@@ -531,16 +614,66 @@ export function generateSimul(db, inv) {
|
||||
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
|
||||
if (taux_interet == null || !duree_mois) return;
|
||||
|
||||
// date_debut_simul remplace le point de départ quand le prêt a été restructuré
|
||||
const startDate = date_debut_simul || date_premiere_echeance || date_souscription;
|
||||
if (!startDate) return;
|
||||
const finDeMois = !!echeance_fin_de_mois;
|
||||
const isRestructuration = !!date_debut_simul;
|
||||
|
||||
// Durée effective : si restructuration, on soustrait les mois déjà écoulés
|
||||
// pour que la simulation se termine bien à la date cible contractuelle d'origine.
|
||||
let effectiveDuree = duree_mois;
|
||||
if (date_debut_simul && date_premiere_echeance && date_debut_simul > date_premiere_echeance) {
|
||||
const elapsed = monthsDiff(date_premiere_echeance, date_debut_simul);
|
||||
effectiveDuree = Math.max(1, duree_mois - elapsed);
|
||||
// En mode restructuration, on a besoin de connaître les échéances déjà honorées
|
||||
// AVANT de calculer la date de départ de la suite de l'échéancier (cf. plus bas) —
|
||||
// simple lecture, sans effet de bord, peut se faire hors transaction.
|
||||
let keptEntries = [];
|
||||
if (isRestructuration) {
|
||||
// Conserver uniquement les échéances déjà payées avant la date de restructuration
|
||||
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
|
||||
// Les échéances de la période creuse (non payées entre fin de la phase initiale
|
||||
// et date_debut_simul) seront supprimées avec tout ce qui suit.
|
||||
keptEntries = db.prepare(`
|
||||
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
|
||||
WHERE sr.investissement_id = ?
|
||||
AND sr.date_prevue < ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM remboursements r
|
||||
WHERE r.investissement_id = sr.investissement_id
|
||||
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
|
||||
)
|
||||
ORDER BY sr.numero_echeance
|
||||
`).all(id, date_debut_simul);
|
||||
}
|
||||
|
||||
let startDate, effectiveDuree = duree_mois, elapsedMonths = 0;
|
||||
|
||||
if (isRestructuration) {
|
||||
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
|
||||
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
|
||||
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
|
||||
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
|
||||
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
|
||||
const dateBasedElapsed = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
|
||||
? monthsDiff(date_premiere_echeance, date_debut_simul)
|
||||
: keptEntries.length; // fallback : nombre de lignes conservées
|
||||
|
||||
// Garde-fou anti-collision : le calcul calendaire ci-dessus peut sous-estimer le nombre
|
||||
// d'échéances déjà écoulées (ex. date_debut_simul tombe le même mois que la dernière
|
||||
// échéance déjà honorée, avant que le mois suivant ne soit atteint), auquel cas la
|
||||
// numérotation reprendrait à un numero_echeance déjà utilisé par une échéance conservée
|
||||
// ci-dessus, provoquant une violation de la contrainte UNIQUE(investissement_id,
|
||||
// numero_echeance) à l'INSERT. On ne redescend donc jamais en dessous du plus haut
|
||||
// numero_echeance déjà conservé.
|
||||
const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0);
|
||||
elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero);
|
||||
effectiveDuree = Math.max(1, duree_mois - elapsedMonths);
|
||||
|
||||
// Date de départ de la suite de l'échéancier : on continue la cadence mensuelle
|
||||
// d'origine (même jour du mois que date_premiere_echeance) à partir de la première
|
||||
// échéance pas encore écoulée — on ne repart PAS littéralement de date_debut_simul,
|
||||
// qui n'est que la date d'effet administrative de la révision (ex. le jour où
|
||||
// l'utilisateur l'a saisie) et peut tomber le même mois qu'une échéance déjà honorée,
|
||||
// ce qui créerait deux échéances dans le même mois (cf. bug signalé par Olivier le
|
||||
// 22/08/26 sur le dossier "L'Olympique" : échéances 19 et 20 incohérentes).
|
||||
const cadenceBase = date_premiere_echeance || date_debut_simul;
|
||||
startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths);
|
||||
} else {
|
||||
startDate = date_premiere_echeance || date_souscription;
|
||||
if (!startDate) return;
|
||||
}
|
||||
|
||||
const echeances = buildSchedule({
|
||||
@@ -550,28 +683,11 @@ export function generateSimul(db, inv) {
|
||||
type: type_remb || 'in_fine',
|
||||
freq: freq_interets || 'mensuel',
|
||||
startDate,
|
||||
finDeMois: !!echeance_fin_de_mois,
|
||||
finDeMois,
|
||||
});
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (date_debut_simul) {
|
||||
// ── Mode restructuration ──────────────────────────────────────────────
|
||||
// Conserver uniquement les échéances déjà payées avant la date de restructuration
|
||||
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
|
||||
// Les échéances de la période creuse (non payées entre fin de la phase initiale
|
||||
// et date_debut_simul) sont supprimées avec tout ce qui suit.
|
||||
const keptEntries = db.prepare(`
|
||||
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
|
||||
WHERE sr.investissement_id = ?
|
||||
AND sr.date_prevue < ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM remboursements r
|
||||
WHERE r.investissement_id = sr.investissement_id
|
||||
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
|
||||
)
|
||||
ORDER BY sr.numero_echeance
|
||||
`).all(id, date_debut_simul);
|
||||
|
||||
if (isRestructuration) {
|
||||
if (keptEntries.length > 0) {
|
||||
// Supprime tout sauf les entrées payées conservées
|
||||
db.prepare(
|
||||
@@ -582,15 +698,6 @@ export function generateSimul(db, inv) {
|
||||
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
|
||||
}
|
||||
|
||||
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
|
||||
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
|
||||
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
|
||||
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
|
||||
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
|
||||
const elapsedMonths = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
|
||||
? monthsDiff(date_premiere_echeance, date_debut_simul)
|
||||
: keptEntries.length; // fallback : nombre de lignes conservées
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO simul_remboursements
|
||||
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
|
||||
|
||||
Reference in new issue
Block a user