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;