"""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 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) 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