Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
import { Router } from 'express';
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';
const router = Router();
const Schema = z.object({
investissement_id: z.number().int().positive(),
numero_echeance: z.number().int().positive(),
date_prevue: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
capital_prevu: z.number().nonnegative().default(0),
interets_prevus: z.number().nonnegative().default(0),
total_prevu: z.number().nonnegative().default(0),
notes: z.string().optional(),
});
/* GET /api/simul/all — toutes les projections pour l'investisseur actif (ou scope=all) */
router.get('/all', (req, res) => {
const userId = req.user.id;
const scopeAll = req.query.scope === 'all';
const rawInvId = req.header('X-Investisseur-Id');
const invId = Number(rawInvId);
let rows;
if (scopeAll) {
rows = db.prepare(`
SELECT s.*, i.plateforme_id, i.investisseur_id
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE inv.user_id = ?
ORDER BY s.date_prevue
`).all(userId);
} else {
if (!invId) return res.status(400).json({ error: 'Missing X-Investisseur-Id' });
const ok = db.prepare('SELECT id FROM investisseurs WHERE id=? AND user_id=?').get(invId, userId);
if (!ok) return res.status(403).json({ error: 'Investisseur not owned by user' });
rows = db.prepare(`
SELECT s.*, i.plateforme_id, i.investisseur_id
FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE i.investisseur_id = ?
ORDER BY s.date_prevue
`).all(invId);
}
res.json(rows);
});
router.use(requireInvestisseur);
function assertOwnedInvestissement(invId, userId) {
const row = db.prepare(`
SELECT i.id, i.montant_investi, i.taux_interet, i.duree_mois, i.type_remb,
i.freq_interets, i.date_premiere_echeance, i.date_souscription
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, userId);
if (!row) throw new HttpError(403, 'Investissement not in scope');
return row;
}
router.get('/', (req, res) => {
const { investissement_id } = req.query;
if (!investissement_id) {
return res.status(400).json({ error: 'investissement_id query param required' });
}
assertOwnedInvestissement(Number(investissement_id), req.user.id);
const rows = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance'
).all(investissement_id);
res.json(rows);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
assertOwnedInvestissement(body.investissement_id, req.user.id);
const r = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu, notes)
VALUES (?,?,?,?,?,?,?)
`).run(
body.investissement_id, body.numero_echeance, body.date_prevue,
body.capital_prevu, body.interets_prevus, body.total_prevu, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
/**
* Auto-generate the simulated repayment schedule for an investissement
* based on its montant, taux, durée, type_remb and freq_interets.
*
* Body: { investissement_id, replace?: boolean }
*/
router.post('/generate', (req, res, next) => {
try {
const { investissement_id, replace = true } = req.body;
if (!investissement_id) throw new HttpError(400, 'investissement_id required');
const inv = assertOwnedInvestissement(Number(investissement_id), req.user.id);
if (!inv.taux_interet || !inv.duree_mois) {
throw new HttpError(400, 'Investissement requires taux_interet and duree_mois');
}
const tx = db.transaction(() => {
if (replace) {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissement_id);
}
const start = inv.date_premiere_echeance || inv.date_souscription;
const echeances = buildSchedule({
montant: inv.montant_investi,
taux: inv.taux_interet,
duree: inv.duree_mois,
type: inv.type_remb || 'in_fine',
freq: inv.freq_interets || 'mensuel',
startDate: start,
});
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of echeances) {
stmt.run(investissement_id, e.n, e.date, e.capital, e.interets, e.total);
}
return echeances.length;
});
const inserted = tx();
res.status(201).json({ inserted });
} catch (e) { next(e); }
});
/**
* Recalcule l'échéancier en tenant compte des remboursements réels enregistrés
* (remboursements anticipés + première période partielle).
* Équivalent à ce qui est déclenché automatiquement après chaque saisie de remboursement.
*
* Body: { investissement_id }
*/
router.post('/recalculate', (req, res, next) => {
try {
const { investissement_id } = req.body;
if (!investissement_id) throw new HttpError(400, 'investissement_id required');
assertOwnedInvestissement(Number(investissement_id), req.user.id);
adjustSimulForActuals(db, Number(investissement_id));
const rows = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance'
).all(investissement_id);
res.json({ recalculated: rows.length, rows });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const owns = db.prepare(`
SELECT s.id FROM simul_remboursements s
JOIN investissements i ON i.id = s.investissement_id
WHERE s.id=? AND i.investisseur_id=?
`).get(req.params.id, req.investisseur.id);
if (!owns) throw new HttpError(404, 'Not found');
db.prepare('DELETE FROM simul_remboursements WHERE id=?').run(req.params.id);
res.status(204).end();
} catch (e) { next(e); }
});
export default router;