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 { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js'; const router = Router(); const Schema = z.object({ type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme']).default('normal'), investissement_id: z.number().int().positive().nullable().optional(), bonus_plateforme_id: z.number().int().positive().nullable().optional(), bonus_investisseur_id: z.number().int().positive().nullable().optional(), date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), capital: z.number().nonnegative().default(0), cashback: z.number().nonnegative().default(0), interets_bruts_avant_local: z.number().nonnegative().default(0), taxe_locale: z.number().nonnegative().default(0), interets_bruts: z.number().nonnegative().default(0), prelev_sociaux: z.number().nonnegative().default(0), prelev_forfaitaire: z.number().nonnegative().default(0), statut: z.enum(['paye','retard','partiel','impaye']).default('paye'), notes: z.string().optional(), methode_remboursement: z.enum(['portefeuille', 'compte_courant']).default('portefeuille'), compte_id: z.number().int().positive().nullable().optional(), }).refine(data => { if (data.type === 'normal') return !!data.investissement_id; return !!data.bonus_plateforme_id; }, { message: 'investissement_id requis pour type normal ; bonus_plateforme_id requis pour les bonus' }); /** * isTaxIndicatif = true → plateforme étrangère ou investissement exonéré PEA-PME : * les prélèvements sont stockés à titre indicatif, mais le montant réellement * versé (net_recu) n'en tient pas compte (capital + cashback + interets_bruts). * isTaxIndicatif = false → plateforme flat_tax non exonérée : * les prélèvements sont retenus à la source, net_recu = capital + cashback + interets_nets. */ function computeChamps(body, isTaxIndicatif = false) { const interets_nets = Math.round((body.interets_bruts - body.prelev_sociaux - body.prelev_forfaitaire) * 100) / 100; const net_recu = isTaxIndicatif ? Math.round(((body.capital || 0) + (body.cashback || 0) + (body.interets_bruts || 0)) * 100) / 100 : Math.round(((body.capital || 0) + (body.cashback || 0) + interets_nets) * 100) / 100; return { interets_nets, net_recu }; } function getTaxIndicatif(investissementId) { if (!investissementId) return false; const row = db.prepare(` SELECT p.fiscalite, i.fiscalite_override FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id WHERE i.id = ? `).get(investissementId); if (!row) return false; return row.fiscalite !== 'flat_tax' || row.fiscalite_override === 'exonere'; } /** * Crée ou supprime le retrait automatique lié à un remboursement. * Appelé après chaque POST/PUT sur un remboursement normal. * - Si methode === 'compte_courant' : supprime l'ancien retrait lié (s'il existe) * puis en insère un nouveau avec source='auto_remboursement'. * - Sinon : supprime simplement l'ancien retrait lié. */ function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) { // Suppression du retrait automatique précédent (s'il y en avait un) db.prepare('DELETE FROM depots_retraits WHERE remboursement_id = ?').run(rembId); if (methode !== 'compte_courant') return; const inv = db.prepare( 'SELECT investisseur_id, plateforme_id, nom_projet FROM investissements WHERE id = ?' ).get(investissementId); if (!inv) return; db.prepare(` INSERT INTO depots_retraits (investisseur_id, plateforme_id, date_operation, type, montant, libelle, source, remboursement_id) VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?) `).run( inv.investisseur_id, inv.plateforme_id, dateRemb, netRecu, `Remboursement — ${inv.nom_projet}`, rembId, ); } function syncInvestissementStatut(investissement_id) { if (!investissement_id) return; const inv = db.prepare('SELECT montant_investi FROM investissements WHERE id=?').get(investissement_id); if (!inv) return; const { total_capital } = db.prepare( 'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id=?' ).get(investissement_id); const { reinvTotal } = db.prepare( 'SELECT COALESCE(SUM(montant), 0) AS reinvTotal FROM reinvestissements WHERE investissement_id=?' ).get(investissement_id); const capitalTotal = inv.montant_investi + (reinvTotal || 0); const restant = Math.round((capitalTotal - total_capital) * 100) / 100; const newStatut = restant <= 0 ? 'rembourse' : 'en_cours'; db.prepare(` UPDATE investissements SET statut = ?, updated_at = datetime('now') WHERE id = ? AND statut IN ('en_cours', 'rembourse') `).run(newStatut, investissement_id); } router.use(requireInvestisseur); function assertOwnedInvestissement(invId, userId) { const row = db.prepare(` SELECT i.id FROM investissements i JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? WHERE i.id = ? `).get(userId, invId); if (!row) throw new HttpError(403, 'Investissement not in scope'); } function assertOwnedInvestisseur(invId, userId) { const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId); if (!row) throw new HttpError(403, 'Investisseur not in scope'); } router.get('/', (req, res) => { const scopeAll = req.query.scope === 'all'; const { from, to, plateforme_id, investissement_id } = req.query; const invCond = scopeAll ? `(i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) OR (r.investissement_id IS NULL AND r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)))` : `(i.investisseur_id = ? OR (r.investissement_id IS NULL AND r.bonus_investisseur_id = ?))`; const invArg = scopeAll ? req.user.id : req.investisseur.id; const outerConds = []; const outerArgs = []; if (from) { outerConds.push('date_remb >= ?'); outerArgs.push(from); } if (to) { outerConds.push('date_remb <= ?'); outerArgs.push(to); } if (plateforme_id) { outerConds.push('plateforme_id = ?'); outerArgs.push(Number(plateforme_id)); } if (investissement_id){ outerConds.push('investissement_id = ?'); outerArgs.push(Number(investissement_id)); } const outerWhere = outerConds.length ? `WHERE ${outerConds.join(' AND ')}` : ''; const rows = db.prepare(` WITH base AS ( SELECT r.*, COALESCE(i.plateforme_id, r.bonus_plateforme_id) AS plateforme_id, COALESCE(i.investisseur_id, r.bonus_investisseur_id) AS resolved_investisseur_id, i.nom_projet AS inv_nom_projet, CASE WHEN r.investissement_id IS NOT NULL THEN i.montant_investi - ( SELECT COALESCE(SUM(r2.capital), 0) FROM remboursements r2 WHERE r2.investissement_id = r.investissement_id AND (r2.date_remb < r.date_remb OR (r2.date_remb = r.date_remb AND r2.id <= r.id)) ) ELSE 0 END AS capital_restant_du FROM remboursements r LEFT JOIN investissements i ON i.id = r.investissement_id WHERE ${invCond} ) SELECT b.*, CASE b.type WHEN 'normal' THEN b.inv_nom_projet WHEN 'bonus_parrainage' THEN '— Bonus Parrainage' WHEN 'bonus_plateforme' THEN '— Bonus Plateforme' ELSE b.inv_nom_projet END AS nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom, plat_inv.nom AS plateforme_detenteur_nom, c.nom AS compte_nom FROM base b LEFT JOIN plateformes p ON p.id = b.plateforme_id LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id LEFT JOIN investisseurs inv ON inv.id = b.resolved_investisseur_id LEFT JOIN comptes c ON c.id = b.compte_id ${outerWhere} ORDER BY b.date_remb DESC, b.id DESC `).all(invArg, invArg, ...outerArgs); res.json(rows); }); router.post('/', (req, res, next) => { try { const body = Schema.parse(req.body); const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id)); if (body.type === 'normal') { assertOwnedInvestissement(body.investissement_id, req.user.id); const r = db.prepare(` INSERT INTO remboursements (type, investissement_id, date_remb, capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id) VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?) `).run( body.investissement_id, body.date_remb, body.capital, body.cashback, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.prelev_sociaux, body.prelev_forfaitaire, interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement, body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null, ); syncInvestissementStatut(body.investissement_id); adjustSimulForActuals(db, body.investissement_id); syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb); // ── Réinvestissement automatique des intérêts ────────────────────────── { const inv = db.prepare( 'SELECT i.auto_reinvest, p.fiscalite FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id WHERE i.id = ?' ).get(body.investissement_id); if (inv?.auto_reinvest) { // Plateforme française (flat_tax) : utiliser les intérêts nets (prélevés à la source) // Autres plateformes : utiliser les intérêts bruts (rien n'est retenu) const montantAuto = inv.fiscalite === 'flat_tax' ? interets_nets : body.interets_bruts; if (montantAuto > 0) { db.prepare(` INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source) VALUES (?, ?, ?, 'Réinvestissement automatique des intérêts', 'auto') `).run(body.investissement_id, montantAuto, body.date_remb); generateSimulWithReinvestissements(db, body.investissement_id); } } } return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu }); } // Bonus (parrainage ou plateforme) const bonusInvestisseurId = body.bonus_investisseur_id ?? req.investisseur.id; assertOwnedInvestisseur(bonusInvestisseurId, req.user.id); const r = db.prepare(` INSERT INTO remboursements (type, bonus_plateforme_id, bonus_investisseur_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes) VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?) `).run( body.type, body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, ); res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets: 0, net_recu: body.cashback }); } catch (e) { next(e); } }); /* ── Retraitement fiscal en masse ───────────────────────────── */ router.post('/reprocess', (req, res, next) => { try { const round2 = v => Math.round(v * 100) / 100; const rembs = db.prepare(` SELECT r.id, r.investissement_id, r.capital, r.cashback, r.date_remb, r.interets_bruts_avant_local, r.taxe_locale, r.interets_bruts, p.fiscalite, p.taux_fiscalite_locale, i.fiscalite_override FROM remboursements r JOIN investissements i ON i.id = r.investissement_id JOIN plateformes p ON p.id = i.plateforme_id JOIN investisseurs inv ON inv.id = i.investisseur_id WHERE r.type = 'normal' AND r.investissement_id IS NOT NULL AND inv.user_id = ? `).all(req.user.id); const pfuRates = db.prepare('SELECT * FROM taux_pfu').all(); const getRates = (dateStr) => { const year = parseInt(dateStr.substring(0, 4), 10); return pfuRates.find(r => r.annee === year) ?? null; }; const stmtUpdate = db.prepare(` UPDATE remboursements SET taxe_locale=?, interets_bruts=?, prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=? WHERE id=? `); const stmtUpdateRetrait = db.prepare(` UPDATE depots_retraits SET montant=? WHERE remboursement_id=? AND source='auto_remboursement' `); let updated = 0; const affectedInvIds = new Set(); db.transaction(() => { for (const r of rembs) { const rates = getRates(r.date_remb); let taxe_locale = 0; let interets_bruts = r.interets_bruts; let prelev_sociaux = 0; let prelev_forfaitaire = 0; if (r.fiscalite === 'avec_fiscalite_locale' && r.taux_fiscalite_locale) { // Base = interets avant taxe locale (fallback sur interets_bruts si colonne vide) const base = (r.interets_bruts_avant_local > 0) ? r.interets_bruts_avant_local : r.interets_bruts; taxe_locale = round2(base * r.taux_fiscalite_locale / 100); interets_bruts = round2(base - taxe_locale); } // Prélèvements calculés pour TOUTES les plateformes (indicatif pour les non-flat_tax) if (rates) { prelev_sociaux = round2(interets_bruts * rates.prelev_sociaux / 100); prelev_forfaitaire = round2(interets_bruts * rates.impot_revenu / 100); } const interets_nets = round2(interets_bruts - prelev_sociaux - prelev_forfaitaire); // Plateforme étrangère ou investissement exonéré : le montant versé n'inclut pas // les prélèvements indicatifs — l'investisseur reçoit le brut intégral. const isTaxIndicatif = r.fiscalite !== 'flat_tax' || r.fiscalite_override === 'exonere'; const net_recu = isTaxIndicatif ? round2((r.capital || 0) + (r.cashback || 0) + interets_bruts) : round2((r.capital || 0) + (r.cashback || 0) + interets_nets); stmtUpdate.run(taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, r.id); stmtUpdateRetrait.run(net_recu, r.id); affectedInvIds.add(r.investissement_id); updated++; } })(); // Régénérer l'échéancier pour chaque investissement impacté for (const invId of affectedInvIds) { adjustSimulForActuals(db, invId); } res.json({ updated }); } catch (e) { next(e); } }); /* ── Modifier un remboursement ──────────────────────────────── */ router.put('/:id', (req, res, next) => { try { const id = Number(req.params.id); const existing = db.prepare(` SELECT r.id, r.type, r.investissement_id, r.bonus_plateforme_id, r.bonus_investisseur_id FROM remboursements r LEFT JOIN investissements i ON i.id = r.investissement_id LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id) WHERE r.id = ? AND inv.user_id = ? `).get(id, req.user.id); if (!existing) throw new HttpError(404, 'Not found'); const body = Schema.parse(req.body); const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id)); if (body.type === 'normal') { db.prepare(` UPDATE remboursements SET date_remb=?, capital=?, cashback=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?, methode_remboursement=?, compte_id=? WHERE id=? `).run( body.date_remb, body.capital, body.cashback, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.prelev_sociaux, body.prelev_forfaitaire, interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement, body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null, id, ); syncInvestissementStatut(body.investissement_id); adjustSimulForActuals(db, body.investissement_id); syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb); } else { const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id; db.prepare(` UPDATE remboursements SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=? WHERE id=? `).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, id); } res.json({ id, ...body, interets_nets, net_recu }); } catch (e) { next(e); } }); /* ── Supprimer un remboursement ─────────────────────────────── */ router.delete('/:id', (req, res, next) => { try { const id = Number(req.params.id); const existing = db.prepare(` SELECT r.id, r.investissement_id FROM remboursements r LEFT JOIN investissements i ON i.id = r.investissement_id LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id) WHERE r.id = ? AND inv.user_id = ? `).get(id, req.user.id); if (!existing) throw new HttpError(404, 'Not found'); syncAutoRetrait(id, 'portefeuille', 0, null, null); // supprime le retrait auto si présent db.prepare('DELETE FROM remboursements WHERE id=?').run(id); if (existing.investissement_id) { syncInvestissementStatut(existing.investissement_id); adjustSimulForActuals(db, existing.investissement_id); } res.json({ deleted: id }); } catch (e) { next(e); } }); /* ── Supprimer tous les remboursements d'un investissement ───── */ router.delete('/', (req, res, next) => { try { const investissement_id = Number(req.query.investissement_id); if (!investissement_id) throw new HttpError(400, 'investissement_id requis'); assertOwnedInvestissement(investissement_id, req.user.id); const rembs = db.prepare('SELECT id FROM remboursements WHERE investissement_id=?').all(investissement_id); for (const r of rembs) { syncAutoRetrait(r.id, 'portefeuille', 0, null, null); } db.prepare('DELETE FROM remboursements WHERE investissement_id=?').run(investissement_id); syncInvestissementStatut(investissement_id); adjustSimulForActuals(db, investissement_id); res.json({ deleted: rembs.length }); } catch (e) { next(e); } }); // ── Backfill compte_id sur les remboursements ────────────────────────────── // Pour chaque remboursement methode=compte_courant sans compte_id : // 1. Utilise le compte_id de l'investissement lié si disponible // 2. Sinon prend le premier compte de type compte_courant du détenteur router.post('/backfill-comptes', (req, res, next) => { try { const rows = db.prepare(` SELECT r.id AS remb_id, i.compte_id AS inv_compte_id, i.investisseur_id FROM remboursements r JOIN investissements i ON i.id = r.investissement_id JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? WHERE r.methode_remboursement = 'compte_courant' AND r.compte_id IS NULL AND r.type = 'normal' `).all(req.user.id); let updated = 0; const stmt = db.prepare('UPDATE remboursements SET compte_id=? WHERE id=?'); for (const row of rows) { let compteId = row.inv_compte_id ?? null; // Fallback : premier compte_courant du détenteur if (!compteId) { const compte = db.prepare( "SELECT id FROM comptes WHERE investisseur_id=? AND user_id=? AND type='compte_courant' ORDER BY id LIMIT 1" ).get(row.investisseur_id, req.user.id); compteId = compte?.id ?? null; } if (compteId) { stmt.run(compteId, row.remb_id); updated++; } } res.json({ updated, total: rows.length }); } catch (e) { next(e); } }); export default router;