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

335 lines
13 KiB
Python

"""Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
import os
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.roles import membre_a_module, membre_a_poste_ou_executif, require_tresorier
from app.core.security import get_current_membre, is_sysadmin
from app.models.depense import Depense
from app.models.membre import Membre
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
from app.services.finance import position_tresorerie, transaction
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
METHODES_VALIDES = {"VIREMENT", "CHEQUE", "COLLECTE"}
UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves"
async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut:
qui = await db.get(Membre, d.qui)
saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None
return DepenseOut(
id=d.id,
date_depense=d.date_depense,
montant=float(d.montant),
qui_prenom=qui.prenom if qui else "?",
quoi=d.quoi,
paye=d.paye,
methode=d.methode,
preuve_nom=d.preuve_nom,
saisi_par_prenom=saisi.prenom if saisi else None,
confirme=d.confirme_par is not None,
cree_le=d.cree_le,
modifie_le=d.modifie_le,
)
@router.post("/", response_model=DepenseOut)
async def soumettre_depense(
req: DepenseCreate,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""S5 — Un membre soumet une dépense pour lui-même; un exécutif peut le faire pour un autre."""
qui_id = membre.id
if req.membre_id and req.membre_id != membre.id:
if not (
await membre_a_poste_ou_executif(membre.id, "trésorier", db)
or await membre_a_module(membre.id, "S5", db)
):
raise HTTPException(status_code=403, detail="Seul un exécutif peut soumettre une dépense pour un autre membre")
beneficiaire = await db.get(Membre, req.membre_id)
if not beneficiaire or beneficiaire.groupe_id != membre.groupe_id or not beneficiaire.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
qui_id = beneficiaire.id
depense = Depense(
groupe_id=membre.groupe_id,
date_depense=req.date_depense if req.date_depense and is_sysadmin(membre) else date.today(),
montant=req.montant,
qui=qui_id,
quoi=req.quoi.strip(),
paye=False,
methode=None, # sera renseigné par le trésorier
saisi_par=membre.id,
)
db.add(depense)
await db.flush()
return await _to_out(depense, db)
@router.post("/{depense_id}/preuve")
async def telecharger_preuve(
depense_id: str,
fichier: UploadFile = File(...),
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Attacher une preuve (reçu, photo) à une dépense."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
peut_joindre = (
depense.saisi_par == membre.id
or depense.qui == membre.id
or await membre_a_poste_ou_executif(membre.id, "trésorier", db)
)
if not peut_joindre:
raise HTTPException(status_code=403, detail="Réservé au demandeur, au bénéficiaire ou à l'exécutif")
# Sauvegarder le fichier
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = os.path.splitext(fichier.filename)[1] if fichier.filename else ".jpg"
nom_fichier = f"{depense_id}{ext}"
chemin = os.path.join(UPLOAD_DIR, nom_fichier)
contenu = await fichier.read()
with open(chemin, "wb") as f:
f.write(contenu)
depense.preuve_nom = nom_fichier
await db.flush()
return {"message": "Preuve enregistrée", "fichier": nom_fichier}
@router.post("/{depense_id}/confirmer", response_model=DepenseOut)
async def confirmer_depense(
depense_id: str,
methode: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme la dépense et renseigne le mode de paiement."""
methode_norm = methode.upper()
if methode_norm not in METHODES_VALIDES:
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}")
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
if methode_norm == "COLLECTE":
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
elif methode_norm in ("CHEQUE", "VIREMENT"):
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.disponible_bancaire:
raise HTTPException(
status_code=400,
detail="Le solde bancaire disponible est insuffisant pour engager cette dépense",
)
avant = snapshot(depense, "paye", "methode", "confirme_par", "date_confirmation")
depense.methode = methode_norm
depense.confirme_par = membre.id
depense.date_confirmation = datetime.now(timezone.utc)
# Si cash (COLLECTE) → payé immédiatement
if methode_norm == "COLLECTE":
depense.paye = True
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi))
# Si CHEQUE/VIREMENT → engagement, en attente de débit
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.traitee",
raison=f"Dépense traitée par {methode_norm.lower()}.",
membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "confirme_par", "date_confirmation"),
)
await db.flush()
return await _to_out(depense, db)
@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut)
async def confirmer_debit(
depense_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que le chèque/virement a été débité du compte."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
if depense.paye:
raise HTTPException(status_code=400, detail="Déjà confirmée comme payée")
if depense.methode not in ("CHEQUE", "VIREMENT"):
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
if position.solde_banque - float(depense.montant) < position.total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
avant = snapshot(depense, "paye", "methode", "date_confirmation")
depense.paye = True
depense.date_confirmation = datetime.now(timezone.utc)
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.debit_confirme",
raison="Débit bancaire de la dépense confirmé.",
membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "date_confirmation"),
)
await db.flush()
return await _to_out(depense, db)
@router.delete("/{depense_id}")
async def rejeter_depense(
depense_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Rejeter une dépense soumise : elle est supprimée du registre."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées")
avant = snapshot(depense, "date_depense", "montant", "qui", "quoi", "paye", "methode", "preuve_nom")
if depense.preuve_nom:
chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom)
if os.path.exists(chemin):
os.remove(chemin)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.rejetee",
raison="Dépense soumise rejetée.",
membre_id=membre.id,
avant=avant,
)
await db.delete(depense)
await db.flush()
return {"message": "Dépense rejetée"}
@router.get("/", response_model=list[DepenseOut])
async def liste_depenses(
limite: int = 30,
paye: bool | None = None,
confirmees: bool | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les dépenses du groupe."""
query = select(Depense).where(Depense.groupe_id == membre.groupe_id)
if paye is not None:
query = query.where(Depense.paye == paye)
if confirmees is not None:
if confirmees:
query = query.where(Depense.confirme_par.isnot(None))
else:
query = query.where(Depense.confirme_par.is_(None))
query = query.order_by(desc(Depense.date_depense)).limit(limite)
result = await db.execute(query)
return [await _to_out(d, db) for d in result.scalars().all()]
@router.get("/a-confirmer")
async def depenses_a_confirmer(
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Dépenses soumises en attente de confirmation par le trésorier."""
result = await db.execute(
select(Depense).where(
Depense.groupe_id == membre.groupe_id,
Depense.confirme_par.is_(None),
).order_by(desc(Depense.cree_le))
)
return [await _to_out(d, db) for d in result.scalars().all()]
@router.patch("/{depense_id}", response_model=DepenseOut)
async def modifier_depense(
depense_id: str,
req: DepenseUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Modifier une dépense (trésorier)."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
avant = snapshot(depense, "paye", "methode", "quoi")
if req.paye is not None:
depense.paye = req.paye
if req.methode is not None:
if req.methode.upper() not in METHODES_VALIDES:
raise HTTPException(status_code=400, detail="Méthode invalide")
depense.methode = req.methode.upper()
if req.quoi is not None:
depense.quoi = req.quoi.strip()
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.modifiee",
raison="Dépense modifiée par le trésorier.",
membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "quoi"),
)
await db.flush()
return await _to_out(depense, db)