188 lines
8.4 KiB
JavaScript
188 lines
8.4 KiB
JavaScript
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;
|