groupe-meditation/backend/app/routers/operations_bancaires.py

396 lines
16 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, snapshot, 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,
avant={},
apres={
"type": op.type,
"montant": float(op.montant),
"date_operation": op.date_operation.isoformat(),
"provenance": provenance_depot if op.type == "depot" else None,
"note": op.note,
},
)
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")
avant = snapshot(reserve, "nom", "note", "active", "montant")
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,
avant=avant,
apres=snapshot(reserve, "nom", "note", "active", "montant"),
)
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
if source and source.groupe_id != membre.groupe_id:
source = None
if destination and destination.groupe_id != membre.groupe_id:
destination = None
if createur and createur.groupe_id != membre.groupe_id:
createur = 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")
avant = snapshot(reserve, "nom", "note", "active", "montant")
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,
avant=avant,
apres=snapshot(reserve, "nom", "note", "active", "montant"),
)
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)
if m and m.groupe_id != membre.groupe_id:
m = None
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