import { Router } from 'express'; import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; const router = Router(); /** * frais_operations — traçabilité des frais facturés par certaines plateformes * (frais d'entrée, frais de gestion, frais sur distribution), qu'ils soient * prélevés "à la source" (aucun mouvement de trésorerie réel — le montant * est simplement documenté à titre de traçabilité) ou payés en "versement * complémentaire" via le porte-monnaie de la plateforme ou un compte courant * du détenteur (dans ce dernier cas, sans impact sur le solde du porte-monnaie). * * Montant stocké : TTC uniquement (pas de détail HT/TVA, non pertinent pour * un particulier — cf. échange avec Olivier, 30/08/26). * * Étape 1 (fondations) : ce module fournit un CRUD complet, mais rien dans * l'application ne crée encore de frais_operations automatiquement — la * saisie à la souscription, la modale dédiée et l'intégration aux Mouvements * viendront dans une prochaine passe. */ const Schema = z.object({ investissement_id: z.number().int().positive(), remboursement_id: z.number().int().positive().nullable().optional(), montant: z.number().positive(), mode_reglement: z.enum(['source', 'portefeuille', 'compte_courant']).default('source'), compte_id: z.number().int().positive().nullable().optional(), date_operation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), origine: z.enum(['souscription', 'remboursement', 'manuel']).default('manuel'), // Classement visible côté utilisateur (31/08/26, demande Olivier) — distinct de `origine` // (qui reste un détail technique de création). 'souscription' est réservée à l'étape // "Frais" du formulaire d'investissement (unique par investissement, cf. assertUniqueSouscription). categorie: z.enum(['souscription', 'gestion', 'distribution', 'autre']).default('autre'), notes: z.string().optional(), }); function assertOwnedInvestissement(invId, userId) { const row = db.prepare(` SELECT i.id, i.plateforme_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'); return row; } function assertRemboursementBelongs(rembId, invId) { if (!rembId) return; const remb = db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?') .get(rembId, invId); if (!remb) throw new HttpError(400, "remboursement_id ne correspond pas à cet investissement"); } export function assertOwnedCompte(compteId, userId) { if (!compteId) return; const compte = db.prepare('SELECT id FROM comptes WHERE id = ? AND user_id = ?').get(compteId, userId); if (!compte) throw new HttpError(403, 'Compte inconnu ou non autorisé'); } /** * Un investissement ne peut avoir qu'UNE seule entrée de catégorie 'souscription' — c'est * l'entrée lue/écrite depuis l'étape "Frais" du formulaire de création/modification de * l'investissement (jamais depuis le bloc "Frais d'opération" de la fiche, qui exclut cette * catégorie de son sélecteur). `excludeId` : à passer lors d'un PUT pour ne pas se bloquer * soi-même. */ function assertUniqueSouscription(investissementId, categorie, excludeId) { if (categorie !== 'souscription') return; const existing = excludeId ? db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription' AND id != ?`).get(investissementId, excludeId) : db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription'`).get(investissementId); if (existing) throw new HttpError(409, 'Un frais de catégorie "souscription" existe déjà pour cet investissement'); } /** * GET /api/frais-operations?investissement_id=123 * Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs), enrichis de * nom_projet/plateforme_nom/investisseur_nom (vue portefeuille, ex. page dédiée Frais — colonne * Détenteur et onglet "Investissements", Étape 11, 02/09/26) — le filtre par investissement_id * n'a pas besoin de cet enrichissement, le contexte est déjà connu côté client. */ router.get('/', (req, res, next) => { try { const { investissement_id } = req.query; let rows; if (investissement_id) { assertOwnedInvestissement(Number(investissement_id), req.user.id); rows = db.prepare(` SELECT f.*, c.nom AS compte_nom FROM frais_operations f LEFT JOIN comptes c ON c.id = f.compte_id WHERE f.investissement_id = ? ORDER BY f.date_operation DESC, f.id DESC `).all(Number(investissement_id)); } else { rows = db.prepare(` SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom FROM frais_operations f JOIN investissements i ON i.id = f.investissement_id JOIN investisseurs inv ON inv.id = i.investisseur_id JOIN plateformes p ON p.id = i.plateforme_id LEFT JOIN comptes c ON c.id = f.compte_id WHERE inv.user_id = ? ORDER BY f.date_operation DESC, f.id DESC `).all(req.user.id); } res.json(rows); } catch (e) { next(e); } }); router.post('/', (req, res, next) => { try { const body = Schema.parse(req.body); const inv = assertOwnedInvestissement(body.investissement_id, req.user.id); assertRemboursementBelongs(body.remboursement_id, body.investissement_id); if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id); assertUniqueSouscription(body.investissement_id, body.categorie); const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null; const r = db.prepare(` INSERT INTO frais_operations (investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes) VALUES (?,?,?,?,?,?,?,?,?,?) `).run( body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null, body.montant, body.mode_reglement, compteId, body.date_operation, body.origine, body.categorie, body.notes || null, ); res.status(201).json({ id: r.lastInsertRowid, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId }); } catch (e) { next(e); } }); router.put('/:id', (req, res, next) => { try { const id = Number(req.params.id); const existing = db.prepare(` SELECT f.id FROM frais_operations f JOIN investissements i ON i.id = f.investissement_id JOIN investisseurs inv ON inv.id = i.investisseur_id WHERE f.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 inv = assertOwnedInvestissement(body.investissement_id, req.user.id); assertRemboursementBelongs(body.remboursement_id, body.investissement_id); if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id); assertUniqueSouscription(body.investissement_id, body.categorie, id); const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null; db.prepare(` UPDATE frais_operations SET investissement_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?, compte_id=?, date_operation=?, origine=?, categorie=?, notes=?, updated_at=datetime('now') WHERE id=? `).run( body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null, body.montant, body.mode_reglement, compteId, body.date_operation, body.origine, body.categorie, body.notes || null, id, ); res.json({ id, plateforme_id: inv.plateforme_id, ...body, compte_id: compteId }); } catch (e) { next(e); } }); router.delete('/:id', (req, res, next) => { try { const id = Number(req.params.id); const existing = db.prepare(` SELECT f.id FROM frais_operations f JOIN investissements i ON i.id = f.investissement_id JOIN investisseurs inv ON inv.id = i.investisseur_id WHERE f.id = ? AND inv.user_id = ? `).get(id, req.user.id); if (!existing) throw new HttpError(404, 'Not found'); db.prepare('DELETE FROM frais_operations WHERE id = ?').run(id); res.status(204).end(); } catch (e) { next(e); } }); export default router;