groupe-meditation/backend/app/routers/operations_bancaires.py
Daniel Allaire 6677b0f57b Ajouter historique métier et protection concurrente
Ajoute modifie_le aux objets sensibles et fait transmettre la version affichée par le frontend pour refuser les écritures périmées avec un message HTTP 409.

Ajoute une table historique_modifications et un service d'historique métier afin de journaliser automatiquement les raisons des changements importants sans intervention de l'utilisateur.

Expose l'historique métier dans le module Journal, distinct du journal technique, et documente les processus utilisateurs ainsi qu'une présentation sobre aux membres.
2026-05-31 10:59:41 -04:00

374 lines
15 KiB
Python

"""Routes — Opérations bancaires (dépôts, retraits)."""
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.roles import require_tresorier
from app.core.security import get_current_membre
from app.models.operation_bancaire import OperationBancaire
from app.models.transaction_comptable import TransactionComptable
from app.models.mouvement_reserve import MouvementReserve
from app.models.reserve import Reserve
from app.models.membre import Membre
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
from app.services.finance import position_tresorerie, reserve_disponible, solde_reserve
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
def _mouvement_reserve(groupe_id, membre_id, montant, type_mouvement, source_id=None, destination_id=None, note=None):
return MouvementReserve(
groupe_id=groupe_id,
source_id=source_id,
destination_id=destination_id,
montant=montant,
type=type_mouvement,
note=note,
cree_par=membre_id,
)
def _ligne_comptable(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str):
return TransactionComptable(
groupe_id=groupe_id,
date_transaction=op.date_operation,
type=op.type,
compte=compte,
montant=op.montant,
sens=sens,
source_table="operations_bancaires",
source_id=op.id,
note=op.note,
cree_par=membre_id,
)
def _ligne_comptable_montant(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str, montant: float):
return TransactionComptable(
groupe_id=groupe_id,
date_transaction=op.date_operation,
type=op.type,
compte=compte,
montant=montant,
sens=sens,
source_table="operations_bancaires",
source_id=op.id,
note=op.note,
cree_par=membre_id,
)
@router.post("/", response_model=OperationBancaireOut)
async def enregistrer_operation(
req: OperationBancaireCreate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Enregistrer un dépôt ou retrait bancaire (trésorier)."""
if req.type not in ("depot", "retrait", "releve"):
raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou 'releve'")
provenance_depot = (req.provenance_depot or "encaisse").lower()
if req.type == "depot" and provenance_depot not in ("encaisse", "don"):
raise HTTPException(status_code=400, detail="Provenance du dépôt invalide")
if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
position = await position_tresorerie(membre.groupe_id, db)
if req.type == "depot" and provenance_depot == "encaisse" and req.montant > position.encaisse:
raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible")
if req.type == "retrait" and req.montant > position.solde_banque:
raise HTTPException(status_code=400, detail="Le retrait ne peut pas dépasser le solde en banque")
if req.type == "retrait" and position.solde_banque - req.montant < position.total_reserves:
raise HTTPException(status_code=400, detail="Le retrait ferait dépasser les réserves par rapport au solde en banque")
if req.type == "releve" and req.montant < position.total_reserves:
raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
op = OperationBancaire(
groupe_id=membre.groupe_id,
type=req.type,
montant=req.montant,
date_operation=req.date_operation,
effectue_par=membre.id,
note=req.note or ("Don déposé directement au compte bancaire" if provenance_depot == "don" else None),
)
db.add(op)
await db.flush()
if op.type == "depot":
if provenance_depot == "don":
db.add(TransactionComptable(
groupe_id=membre.groupe_id,
date_transaction=op.date_operation,
type="don",
compte="banque",
montant=op.montant,
sens="debit",
source_table="operations_bancaires",
source_id=op.id,
note=op.note,
cree_par=membre.id,
))
else:
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "debit"))
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "credit"))
elif op.type == "retrait":
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "credit"))
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit"))
else:
delta = req.montant - position.solde_banque
if delta != 0:
db.add(_ligne_comptable_montant(
membre.groupe_id, membre.id, op, "banque",
"debit" if delta > 0 else "credit",
abs(delta),
))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="operations_bancaires",
objet_id=op.id,
action=f"operation.{op.type}",
raison=f"Opération bancaire enregistrée : {op.type}.",
membre_id=membre.id,
)
await db.flush()
return OperationBancaireOut(
id=op.id,
type=op.type,
montant=float(op.montant),
date_operation=op.date_operation,
effectue_par_prenom=membre.prenom,
note=op.note,
cree_le=op.cree_le,
)
@router.get("/reserves", response_model=list[ReserveOut])
async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Reserve).where(Reserve.groupe_id == membre.groupe_id, Reserve.active == True)
.order_by(Reserve.nom)
)
return [
ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le, modifie_le=r.modifie_le)
for r in result.scalars().all()
]
@router.get("/registre-comptable", response_model=list[TransactionComptableOut])
async def registre_comptable(
limite: int = 200,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(TransactionComptable)
.where(TransactionComptable.groupe_id == membre.groupe_id)
.order_by(desc(TransactionComptable.date_transaction), desc(TransactionComptable.cree_le))
.limit(limite)
)
transactions = result.scalars().all()
out = []
for tx in transactions:
createur = await db.get(Membre, tx.cree_par) if tx.cree_par else None
out.append(TransactionComptableOut(
id=tx.id,
date_transaction=tx.date_transaction,
type=tx.type,
compte=tx.compte,
montant=float(tx.montant),
sens=tx.sens,
source_table=tx.source_table,
source_id=tx.source_id,
note=tx.note,
cree_par_prenom=createur.prenom if createur else None,
cree_le=tx.cree_le,
))
return out
@router.post("/reserves", response_model=ReserveOut)
async def creer_reserve(req: ReserveCreate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
if req.montant != 0:
raise HTTPException(status_code=400, detail="Créer la réserve à zéro, puis utiliser un virement pour l'approvisionner")
reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=0, note=req.note)
db.add(reserve)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le)
@router.patch("/reserves/{reserve_id}", response_model=ReserveOut)
async def modifier_reserve(
reserve_id: str,
req: ReserveUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves")
ancien_montant = await solde_reserve(reserve.id, db)
if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2):
raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement")
if req.nom is not None:
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
reserve.nom = req.nom.strip()
if req.note is not None:
reserve.note = req.note.strip() or None
reserve.montant = await solde_reserve(reserve.id, db)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="reserves",
objet_id=reserve.id,
action="reserve.modifiee",
raison="Réserve renommée ou note modifiée.",
membre_id=membre.id,
)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le)
@router.get("/reserves/mouvements", response_model=list[ReserveMouvementOut])
async def liste_mouvements_reserves(
limite: int = 100,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(MouvementReserve)
.where(MouvementReserve.groupe_id == membre.groupe_id)
.order_by(desc(MouvementReserve.cree_le))
.limit(limite)
)
mouvements = result.scalars().all()
out = []
for mv in mouvements:
source = await db.get(Reserve, mv.source_id) if mv.source_id else None
destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None
createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None
out.append(ReserveMouvementOut(
id=mv.id,
source_id=mv.source_id,
source_nom=source.nom if source else "Disponible",
destination_id=mv.destination_id,
destination_nom=destination.nom if destination else "Disponible",
montant=float(mv.montant),
type=mv.type,
note=mv.note,
cree_par_prenom=createur.prenom if createur else None,
cree_le=mv.cree_le,
))
return out
@router.post("/reserves/transfert")
async def transferer_reserve(req: ReserveTransfer, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
if req.source_id == req.destination_id:
raise HTTPException(status_code=400, detail="La source et la destination doivent être différentes")
source = None if req.source_id == "disponible" else await db.get(Reserve, req.source_id)
destination = None if req.destination_id == "disponible" else await db.get(Reserve, req.destination_id)
if req.source_id != "disponible" and (not source or source.groupe_id != membre.groupe_id or not source.active):
raise HTTPException(status_code=404, detail="Réserve source introuvable")
if req.destination_id != "disponible" and (not destination or destination.groupe_id != membre.groupe_id or not destination.active):
raise HTTPException(status_code=404, detail="Réserve destination introuvable")
if req.source_id == "disponible":
disponible = await reserve_disponible(membre.groupe_id, db)
if req.montant > disponible:
raise HTTPException(status_code=400, detail="La réserve disponible est insuffisante")
elif req.montant > await solde_reserve(source.id, db):
raise HTTPException(status_code=400, detail="La réserve source est insuffisante")
if source:
source.montant = await solde_reserve(source.id, db) - req.montant
if destination:
destination.montant = await solde_reserve(destination.id, db) + req.montant
db.add(_mouvement_reserve(
membre.groupe_id,
membre.id,
req.montant,
"transfert",
source_id=source.id if source else None,
destination_id=destination.id if destination else None,
))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="mouvements_reserves",
objet_id=source.id if source else (destination.id if destination else membre.groupe_id),
action="reserve.transfert",
raison="Virement entre réserves ou disponible enregistré.",
membre_id=membre.id,
avant={"source_id": req.source_id, "destination_id": req.destination_id},
apres={"montant": req.montant},
)
await db.flush()
return {"message": "Transfert effectué"}
@router.delete("/reserves/{reserve_id}")
async def supprimer_reserve(
reserve_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves")
montant = await solde_reserve(reserve.id, db)
if round(montant, 2) != 0:
raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée")
reserve.active = False
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="reserves",
objet_id=reserve.id,
action="reserve.supprimee",
raison="Réserve supprimée après retour à zéro.",
membre_id=membre.id,
)
await db.flush()
return {"message": "Réserve supprimée"}
@router.get("/", response_model=list[OperationBancaireOut])
async def liste_operations(
limite: int = 20,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les opérations bancaires du groupe."""
result = await db.execute(
select(OperationBancaire)
.where(OperationBancaire.groupe_id == membre.groupe_id)
.order_by(desc(OperationBancaire.date_operation))
.limit(limite)
)
ops = result.scalars().all()
out = []
for op in ops:
m = await db.get(Membre, op.effectue_par)
out.append(OperationBancaireOut(
id=op.id,
type=op.type,
montant=float(op.montant),
date_operation=op.date_operation,
effectue_par_prenom=m.prenom if m else "?",
note=op.note,
cree_le=op.cree_le,
))
return out