Implementation de la notion d'espace d'investissement
This commit is contained in:
1 parent
6b6cad7cd6
commit
4b7df66862
54 files changed
+6782
-288
No files matched your search
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,14 +17,16 @@ const router = Router();
|
||||
* 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.
|
||||
* Depuis le 17/09/26 (chantier "frais PE"), une ligne se rattache SOIT à un
|
||||
* investissement crowdlending (investissement_id), SOIT à un deal Private
|
||||
* Equity (investissement_pe_id) — jamais les deux (CHECK en base). Le corps
|
||||
* de requête doit donc porter exactement l'un des deux champs ; toutes les
|
||||
* routes ci-dessous se branchent sur celui qui est renseigné.
|
||||
*/
|
||||
|
||||
const Schema = z.object({
|
||||
investissement_id: z.number().int().positive(),
|
||||
investissement_id: z.number().int().positive().nullable().optional(),
|
||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
||||
remboursement_id: z.number().int().positive().nullable().optional(),
|
||||
montant: z.number().positive(),
|
||||
mode_reglement: z.enum(['source', 'portefeuille', 'compte_courant']).default('source'),
|
||||
@@ -35,7 +38,10 @@ const Schema = z.object({
|
||||
// "Frais" du formulaire d'investissement (unique par investissement, cf. assertUniqueSouscription).
|
||||
categorie: z.enum(['souscription', 'gestion', 'distribution', 'autre']).default('autre'),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
}).refine(
|
||||
b => (b.investissement_id != null) !== (b.investissement_pe_id != null),
|
||||
{ message: 'Exactement un de investissement_id ou investissement_pe_id doit être renseigné' },
|
||||
);
|
||||
|
||||
function assertOwnedInvestissement(invId, userId) {
|
||||
const row = db.prepare(`
|
||||
@@ -47,10 +53,31 @@ function assertOwnedInvestissement(invId, userId) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function assertRemboursementBelongs(rembId, invId) {
|
||||
/** Équivalent pour les deals PE (investissements_pe) — bornée au workspace actif, même
|
||||
* principe que ownedDeal() dans investissementsPe.js. */
|
||||
function assertOwnedInvestissementPe(invPeId, req) {
|
||||
const row = db.prepare(`
|
||||
SELECT ipe.id, ipe.plateforme_id FROM investissements_pe ipe
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id AND inv.user_id = ?
|
||||
WHERE ipe.id = ? AND ipe.workspace_id = ?
|
||||
`).get(req.user.id, invPeId, resolveActiveWorkspaceId(req));
|
||||
if (!row) throw new HttpError(403, 'Investissement PE not in scope');
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Résout et vérifie la référence portée par le corps de requête (crowdlending ou PE),
|
||||
* renvoie { id, plateforme_id } de l'investissement/deal correspondant. */
|
||||
function assertOwnedInvestissementEither(body, req) {
|
||||
return body.investissement_id
|
||||
? assertOwnedInvestissement(body.investissement_id, req.user.id)
|
||||
: assertOwnedInvestissementPe(body.investissement_pe_id, req);
|
||||
}
|
||||
|
||||
function assertRemboursementBelongs(rembId, body) {
|
||||
if (!rembId) return;
|
||||
const remb = db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?')
|
||||
.get(rembId, invId);
|
||||
const remb = body.investissement_id
|
||||
? db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?').get(rembId, body.investissement_id)
|
||||
: db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_pe_id = ?').get(rembId, body.investissement_pe_id);
|
||||
if (!remb) throw new HttpError(400, "remboursement_id ne correspond pas à cet investissement");
|
||||
}
|
||||
|
||||
@@ -61,30 +88,34 @@ export function assertOwnedCompte(compteId, userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Un investissement (ou un deal PE) 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 (jamais depuis le bloc "Frais d'opération"/la page Frais, qui
|
||||
* excluent cette catégorie de leur sélecteur). `excludeId` : à passer lors d'un PUT pour
|
||||
* ne pas se bloquer soi-même.
|
||||
*/
|
||||
function assertUniqueSouscription(investissementId, categorie, excludeId) {
|
||||
function assertUniqueSouscription(body, categorie, excludeId) {
|
||||
if (categorie !== 'souscription') return;
|
||||
const col = body.investissement_id ? 'investissement_id' : 'investissement_pe_id';
|
||||
const val = body.investissement_id ?? body.investissement_pe_id;
|
||||
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);
|
||||
? db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription' AND id != ?`).get(val, excludeId)
|
||||
: db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription'`).get(val);
|
||||
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.
|
||||
* GET /api/frais-operations?investissement_id=123 | ?investissement_pe_id=456
|
||||
* Sans filtre : tous les frais de l'utilisateur pour le workspace ACTIF (résolu via
|
||||
* resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending ou PE selon le slug
|
||||
* du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/investisseur_nom (vue
|
||||
* portefeuille, ex. page dédiée Frais/FraisPe). Le filtre par investissement_id/
|
||||
* investissement_pe_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;
|
||||
const { investissement_id, investissement_pe_id } = req.query;
|
||||
let rows;
|
||||
if (investissement_id) {
|
||||
assertOwnedInvestissement(Number(investissement_id), req.user.id);
|
||||
@@ -95,17 +126,41 @@ router.get('/', (req, res, next) => {
|
||||
WHERE f.investissement_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(Number(investissement_id));
|
||||
} else {
|
||||
} else if (investissement_pe_id) {
|
||||
assertOwnedInvestissementPe(Number(investissement_pe_id), req);
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||
SELECT f.*, c.nom AS compte_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 = ?
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE f.investissement_pe_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id);
|
||||
`).all(Number(investissement_pe_id));
|
||||
} else {
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
const ws = db.prepare('SELECT slug FROM workspaces WHERE id = ?').get(workspaceId);
|
||||
if (!ws || ws.slug === 'crowdlending') {
|
||||
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);
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||
FROM frais_operations f
|
||||
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE inv.user_id = ? AND ipe.workspace_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id, workspaceId);
|
||||
}
|
||||
}
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
@@ -114,19 +169,19 @@ router.get('/', (req, res, next) => {
|
||||
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);
|
||||
const inv = assertOwnedInvestissementEither(body, req);
|
||||
assertRemboursementBelongs(body.remboursement_id, body);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie);
|
||||
assertUniqueSouscription(body, 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 (?,?,?,?,?,?,?,?,?,?)
|
||||
(investissement_id, investissement_pe_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.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
);
|
||||
@@ -137,29 +192,27 @@ router.post('/', (req, res, next) => {
|
||||
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);
|
||||
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
// Vérifie que l'utilisateur possédait déjà cette ligne, quel que soit son type actuel.
|
||||
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
|
||||
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
|
||||
|
||||
const body = Schema.parse(req.body);
|
||||
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||
const inv = assertOwnedInvestissementEither(body, req);
|
||||
assertRemboursementBelongs(body.remboursement_id, body);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie, id);
|
||||
assertUniqueSouscription(body, 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=?,
|
||||
SET investissement_id=?, investissement_pe_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.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
id,
|
||||
@@ -171,13 +224,10 @@ router.put('/:id', (req, res, next) => {
|
||||
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);
|
||||
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
|
||||
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
|
||||
|
||||
db.prepare('DELETE FROM frais_operations WHERE id = ?').run(id);
|
||||
res.status(204).end();
|
||||
|
||||
Reference in new issue
Block a user