448 lines
16 KiB
Python
448 lines
16 KiB
Python
|
|
"""
|
||
|
|
DragonBank - Modele Transaction
|
||
|
|
=================================
|
||
|
|
Classe DAO gerant les virements et l'historique des transactions.
|
||
|
|
|
||
|
|
Chaque virement est atomique : debit et credit s'effectuent dans
|
||
|
|
la meme transaction SQL. Si l'une echoue, tout est annule.
|
||
|
|
|
||
|
|
Version : 3.0
|
||
|
|
"""
|
||
|
|
|
||
|
|
import decimal
|
||
|
|
import logging
|
||
|
|
import io
|
||
|
|
import csv
|
||
|
|
|
||
|
|
from flask import Response
|
||
|
|
|
||
|
|
from database import serialiser_ligne, serialiser_lignes
|
||
|
|
from config import LIMITE_TRANSACTIONS_DEFAUT, LIMITE_TRANSACTIONS_MAX
|
||
|
|
|
||
|
|
journaliseur = logging.getLogger('dragonbank.transaction')
|
||
|
|
|
||
|
|
# Noms lisibles pour les types de transactions (utilises dans l'export CSV).
|
||
|
|
NOMS_TYPES = {
|
||
|
|
'virement_interne': 'Virement interne',
|
||
|
|
'virement_entre_personnes': 'Virement entre personnes',
|
||
|
|
'virement_externe': 'Virement externe',
|
||
|
|
'depot': 'Depot',
|
||
|
|
'retrait': 'Retrait',
|
||
|
|
'interets': 'Interets'
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class TransactionDAO:
|
||
|
|
"""
|
||
|
|
Objet d'acces aux donnees pour la table transactions.
|
||
|
|
|
||
|
|
Contient la logique des trois types de virements :
|
||
|
|
- Interne : entre les propres comptes de l'utilisateur.
|
||
|
|
- Entre personnes : vers un beneficiaire DragonBank.
|
||
|
|
- Externe : depuis/vers une banque tierce (simule).
|
||
|
|
"""
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# LECTURE
|
||
|
|
# =========================================================
|
||
|
|
|
||
|
|
def lister(self, connexion, id_utilisateur, id_compte=None,
|
||
|
|
type_transaction=None, limite=None):
|
||
|
|
"""
|
||
|
|
Retourne l'historique des transactions d'un utilisateur.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_utilisateur : UUID de l'utilisateur.
|
||
|
|
id_compte (str) : Filtrer par compte (optionnel).
|
||
|
|
type_transaction : Filtrer par type (optionnel).
|
||
|
|
limite (int) : Nombre max de resultats (defaut: 50).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
list[dict]: Transactions triees par date decroissante.
|
||
|
|
"""
|
||
|
|
if limite is None:
|
||
|
|
limite = LIMITE_TRANSACTIONS_DEFAUT
|
||
|
|
if limite > LIMITE_TRANSACTIONS_MAX:
|
||
|
|
limite = LIMITE_TRANSACTIONS_MAX
|
||
|
|
|
||
|
|
requete_base = """
|
||
|
|
SELECT t.id, t.from_account_id, t.to_account_id, t.transaction_type,
|
||
|
|
t.amount, t.currency, t.description, t.status, t.reference,
|
||
|
|
t.external_bank_name, t.external_account_number,
|
||
|
|
t.created_at, t.executed_at,
|
||
|
|
fa.account_number AS from_account_number,
|
||
|
|
ta.account_number AS to_account_number
|
||
|
|
FROM transactions t
|
||
|
|
LEFT JOIN accounts fa ON t.from_account_id = fa.id
|
||
|
|
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
||
|
|
"""
|
||
|
|
|
||
|
|
if id_compte:
|
||
|
|
clause_where = "WHERE (t.from_account_id = %s OR t.to_account_id = %s)"
|
||
|
|
parametres = [id_compte, id_compte]
|
||
|
|
else:
|
||
|
|
clause_where = "WHERE (fa.user_id = %s OR ta.user_id = %s)"
|
||
|
|
parametres = [id_utilisateur, id_utilisateur]
|
||
|
|
|
||
|
|
if type_transaction:
|
||
|
|
clause_where = clause_where + " AND t.transaction_type = %s"
|
||
|
|
parametres.append(type_transaction)
|
||
|
|
|
||
|
|
parametres.append(limite)
|
||
|
|
|
||
|
|
curseur = connexion.cursor()
|
||
|
|
curseur.execute(
|
||
|
|
requete_base + ' ' + clause_where + ' ORDER BY t.created_at DESC LIMIT %s',
|
||
|
|
parametres
|
||
|
|
)
|
||
|
|
return serialiser_lignes(curseur.fetchall())
|
||
|
|
|
||
|
|
def historique_solde(self, connexion, id_compte, solde_actuel):
|
||
|
|
"""
|
||
|
|
Reconstitue l'evolution du solde d'un compte dans le temps.
|
||
|
|
|
||
|
|
Utilise une back-calculation : on part du solde actuel
|
||
|
|
et on remonte les transactions pour retrouver les soldes passes.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_compte : UUID du compte.
|
||
|
|
solde_actuel : Solde actuel du compte (float).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
list[dict]: Points {date, solde} en ordre chronologique.
|
||
|
|
"""
|
||
|
|
curseur = connexion.cursor()
|
||
|
|
curseur.execute(
|
||
|
|
"""
|
||
|
|
SELECT created_at, amount, from_account_id, to_account_id
|
||
|
|
FROM transactions
|
||
|
|
WHERE (from_account_id = %s OR to_account_id = %s)
|
||
|
|
AND status = 'completed'
|
||
|
|
ORDER BY created_at DESC
|
||
|
|
""",
|
||
|
|
(id_compte, id_compte)
|
||
|
|
)
|
||
|
|
transactions = curseur.fetchall()
|
||
|
|
|
||
|
|
solde_courant = float(solde_actuel)
|
||
|
|
points = []
|
||
|
|
|
||
|
|
for transaction in transactions:
|
||
|
|
montant = float(transaction['amount'])
|
||
|
|
est_debit = str(transaction['from_account_id']) == id_compte
|
||
|
|
|
||
|
|
points.append({
|
||
|
|
'date': str(transaction['created_at'])[:10],
|
||
|
|
'solde': round(solde_courant, 2)
|
||
|
|
})
|
||
|
|
|
||
|
|
if est_debit:
|
||
|
|
solde_courant = solde_courant + montant
|
||
|
|
else:
|
||
|
|
solde_courant = solde_courant - montant
|
||
|
|
|
||
|
|
points.append({'date': 'Ouverture', 'solde': round(solde_courant, 2)})
|
||
|
|
points.reverse()
|
||
|
|
return points
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# VIREMENTS
|
||
|
|
# =========================================================
|
||
|
|
|
||
|
|
def virement_interne(self, connexion, id_source, id_destination,
|
||
|
|
montant, libelle, compte_dao):
|
||
|
|
"""
|
||
|
|
Effectue un virement entre deux comptes du meme utilisateur.
|
||
|
|
|
||
|
|
Verifie le solde puis debite la source et credite la destination
|
||
|
|
dans la meme transaction SQL (atomicite).
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_source (str) : UUID du compte source.
|
||
|
|
id_destination : UUID du compte destination.
|
||
|
|
montant : Montant (Decimal).
|
||
|
|
libelle (str) : Description du virement.
|
||
|
|
compte_dao : Instance de CompteDAO pour les mises a jour.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: La transaction creee.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValueError: Si le solde est insuffisant.
|
||
|
|
"""
|
||
|
|
self._verifier_solde_suffisant(connexion, id_source, montant)
|
||
|
|
|
||
|
|
compte_dao.debiter(connexion, id_source, montant)
|
||
|
|
compte_dao.crediter(connexion, id_destination, montant)
|
||
|
|
|
||
|
|
return self._enregistrer(
|
||
|
|
connexion,
|
||
|
|
from_id=id_source,
|
||
|
|
to_id=id_destination,
|
||
|
|
type_t='virement_interne',
|
||
|
|
montant=montant,
|
||
|
|
libelle=libelle
|
||
|
|
)
|
||
|
|
|
||
|
|
def virement_entre_personnes(self, connexion, id_source, id_destination,
|
||
|
|
montant, libelle, compte_dao):
|
||
|
|
"""
|
||
|
|
Effectue un virement vers un beneficiaire DragonBank.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_source (str) : UUID du compte source.
|
||
|
|
id_destination : UUID du compte destination (beneficiaire).
|
||
|
|
montant : Montant (Decimal).
|
||
|
|
libelle (str) : Description du virement.
|
||
|
|
compte_dao : Instance de CompteDAO.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: La transaction creee.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValueError: Si le solde est insuffisant.
|
||
|
|
"""
|
||
|
|
self._verifier_solde_suffisant(connexion, id_source, montant)
|
||
|
|
|
||
|
|
compte_dao.debiter(connexion, id_source, montant)
|
||
|
|
compte_dao.crediter(connexion, id_destination, montant)
|
||
|
|
|
||
|
|
return self._enregistrer(
|
||
|
|
connexion,
|
||
|
|
from_id=id_source,
|
||
|
|
to_id=id_destination,
|
||
|
|
type_t='virement_entre_personnes',
|
||
|
|
montant=montant,
|
||
|
|
libelle=libelle
|
||
|
|
)
|
||
|
|
|
||
|
|
def virement_externe(self, connexion, id_compte, montant, direction,
|
||
|
|
nom_banque, numero_externe, libelle, compte_dao):
|
||
|
|
"""
|
||
|
|
Simule un virement depuis ou vers une banque externe.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_compte (str) : UUID du compte DragonBank.
|
||
|
|
montant : Montant (Decimal).
|
||
|
|
direction (str) : 'outgoing' ou 'incoming'.
|
||
|
|
nom_banque (str): Nom de la banque externe.
|
||
|
|
numero_externe : Numero de compte externe.
|
||
|
|
libelle (str) : Description.
|
||
|
|
compte_dao : Instance de CompteDAO.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: La transaction creee.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValueError: Si le solde est insuffisant (direction outgoing).
|
||
|
|
"""
|
||
|
|
if direction == 'outgoing':
|
||
|
|
self._verifier_solde_suffisant(connexion, id_compte, montant)
|
||
|
|
compte_dao.debiter(connexion, id_compte, montant)
|
||
|
|
from_id = id_compte
|
||
|
|
to_id = None
|
||
|
|
else:
|
||
|
|
compte_dao.crediter(connexion, id_compte, montant)
|
||
|
|
from_id = None
|
||
|
|
to_id = id_compte
|
||
|
|
|
||
|
|
return self._enregistrer(
|
||
|
|
connexion,
|
||
|
|
from_id=from_id,
|
||
|
|
to_id=to_id,
|
||
|
|
type_t='virement_externe',
|
||
|
|
montant=montant,
|
||
|
|
libelle=libelle,
|
||
|
|
banque_externe=nom_banque,
|
||
|
|
numero_externe=numero_externe
|
||
|
|
)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# EXPORT CSV
|
||
|
|
# =========================================================
|
||
|
|
|
||
|
|
def exporter_csv(self, connexion, id_utilisateur, id_compte=None):
|
||
|
|
"""
|
||
|
|
Genere un fichier CSV de l'historique des transactions.
|
||
|
|
|
||
|
|
Le fichier est encode en UTF-8 avec BOM (utf-8-sig) pour
|
||
|
|
une compatibilite optimale avec Microsoft Excel sous Windows.
|
||
|
|
Le separateur est le point-virgule (standard europeen).
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_utilisateur : UUID de l'utilisateur.
|
||
|
|
id_compte (str) : Restreindre a un compte (optionnel).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
flask.Response: Reponse HTTP avec le fichier CSV en piece jointe.
|
||
|
|
"""
|
||
|
|
if id_compte:
|
||
|
|
curseur = connexion.cursor()
|
||
|
|
curseur.execute(
|
||
|
|
"""
|
||
|
|
SELECT t.created_at, t.transaction_type, t.description,
|
||
|
|
t.amount, t.status,
|
||
|
|
fa.account_number AS from_account,
|
||
|
|
ta.account_number AS to_account,
|
||
|
|
t.external_bank_name
|
||
|
|
FROM transactions t
|
||
|
|
LEFT JOIN accounts fa ON t.from_account_id = fa.id
|
||
|
|
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
||
|
|
WHERE (t.from_account_id = %s OR t.to_account_id = %s)
|
||
|
|
ORDER BY t.created_at DESC
|
||
|
|
""",
|
||
|
|
(id_compte, id_compte)
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
curseur = connexion.cursor()
|
||
|
|
curseur.execute(
|
||
|
|
"""
|
||
|
|
SELECT t.created_at, t.transaction_type, t.description,
|
||
|
|
t.amount, t.status,
|
||
|
|
fa.account_number AS from_account,
|
||
|
|
ta.account_number AS to_account,
|
||
|
|
t.external_bank_name
|
||
|
|
FROM transactions t
|
||
|
|
LEFT JOIN accounts fa ON t.from_account_id = fa.id
|
||
|
|
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
||
|
|
WHERE (fa.user_id = %s OR ta.user_id = %s)
|
||
|
|
ORDER BY t.created_at DESC
|
||
|
|
""",
|
||
|
|
(id_utilisateur, id_utilisateur)
|
||
|
|
)
|
||
|
|
|
||
|
|
lignes = curseur.fetchall()
|
||
|
|
|
||
|
|
sortie = io.StringIO()
|
||
|
|
writer = csv.writer(sortie, delimiter=';', quoting=csv.QUOTE_ALL)
|
||
|
|
|
||
|
|
writer.writerow([
|
||
|
|
'Date', 'Type', 'Libelle', 'Montant (EUR)',
|
||
|
|
'Statut', 'Compte source', 'Compte destination', 'Banque externe'
|
||
|
|
])
|
||
|
|
|
||
|
|
for ligne in lignes:
|
||
|
|
date = str(ligne['created_at'])[:16].replace('T', ' ') if ligne['created_at'] else ''
|
||
|
|
writer.writerow([
|
||
|
|
date,
|
||
|
|
NOMS_TYPES.get(ligne['transaction_type'], ligne['transaction_type']),
|
||
|
|
ligne['description'] or '',
|
||
|
|
'{:.2f}'.format(float(ligne['amount'])),
|
||
|
|
ligne['status'] or '',
|
||
|
|
ligne['from_account'] or '',
|
||
|
|
ligne['to_account'] or '',
|
||
|
|
ligne['external_bank_name'] or ''
|
||
|
|
])
|
||
|
|
|
||
|
|
contenu = sortie.getvalue()
|
||
|
|
sortie.close()
|
||
|
|
|
||
|
|
return Response(
|
||
|
|
contenu.encode('utf-8-sig'),
|
||
|
|
mimetype='text/csv',
|
||
|
|
headers={
|
||
|
|
'Content-Disposition': 'attachment; filename="dragonbank_transactions.csv"',
|
||
|
|
'Content-Type': 'text/csv; charset=utf-8'
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# STATISTIQUES
|
||
|
|
# =========================================================
|
||
|
|
|
||
|
|
def statistiques(self, connexion, id_utilisateur):
|
||
|
|
"""
|
||
|
|
Calcule les statistiques de transactions du mois courant.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_utilisateur : UUID de l'utilisateur.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: {transactions_mois, total_envoye, total_recu}
|
||
|
|
"""
|
||
|
|
curseur = connexion.cursor()
|
||
|
|
curseur.execute(
|
||
|
|
"""
|
||
|
|
SELECT COUNT(*) AS transactions_mois,
|
||
|
|
COALESCE(SUM(CASE WHEN fa.user_id = %s THEN t.amount ELSE 0 END), 0) AS total_envoye,
|
||
|
|
COALESCE(SUM(CASE WHEN ta.user_id = %s THEN t.amount ELSE 0 END), 0) AS total_recu
|
||
|
|
FROM transactions t
|
||
|
|
LEFT JOIN accounts fa ON t.from_account_id = fa.id
|
||
|
|
LEFT JOIN accounts ta ON t.to_account_id = ta.id
|
||
|
|
WHERE (fa.user_id = %s OR ta.user_id = %s)
|
||
|
|
AND t.created_at >= date_trunc('month', CURRENT_DATE)
|
||
|
|
AND t.status = 'completed'
|
||
|
|
""",
|
||
|
|
(id_utilisateur, id_utilisateur, id_utilisateur, id_utilisateur)
|
||
|
|
)
|
||
|
|
return curseur.fetchone()
|
||
|
|
|
||
|
|
# =========================================================
|
||
|
|
# UTILITAIRES PRIVES
|
||
|
|
# =========================================================
|
||
|
|
|
||
|
|
def _verifier_solde_suffisant(self, connexion, id_compte, montant):
|
||
|
|
"""
|
||
|
|
Verifie que le solde du compte est suffisant pour le debit.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
id_compte : UUID du compte a verifier.
|
||
|
|
montant : Montant a debiter (Decimal).
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValueError: Si le solde est inferieur au montant.
|
||
|
|
"""
|
||
|
|
curseur = connexion.cursor()
|
||
|
|
curseur.execute('SELECT balance FROM accounts WHERE id = %s', (id_compte,))
|
||
|
|
resultat = curseur.fetchone()
|
||
|
|
if resultat is None:
|
||
|
|
raise ValueError("Compte introuvable")
|
||
|
|
|
||
|
|
solde = decimal.Decimal(str(resultat['balance']))
|
||
|
|
if solde < montant:
|
||
|
|
raise ValueError(
|
||
|
|
"Solde insuffisant (disponible : "
|
||
|
|
+ '{:.2f}'.format(float(solde)) + " euros)"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _enregistrer(self, connexion, from_id, to_id, type_t,
|
||
|
|
montant, libelle, banque_externe=None, numero_externe=None):
|
||
|
|
"""
|
||
|
|
Insere une nouvelle transaction en base de donnees.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
connexion : Connexion PostgreSQL active.
|
||
|
|
from_id : UUID du compte source (ou None).
|
||
|
|
to_id : UUID du compte destination (ou None).
|
||
|
|
type_t (str) : Type de transaction.
|
||
|
|
montant : Montant (Decimal ou float).
|
||
|
|
libelle (str) : Description.
|
||
|
|
banque_externe : Nom de la banque externe (optionnel).
|
||
|
|
numero_externe : Numero de compte externe (optionnel).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: La transaction inseree.
|
||
|
|
"""
|
||
|
|
curseur = connexion.cursor()
|
||
|
|
curseur.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO transactions
|
||
|
|
(from_account_id, to_account_id, transaction_type, amount,
|
||
|
|
description, status, external_bank_name,
|
||
|
|
external_account_number, executed_at)
|
||
|
|
VALUES (%s, %s, %s, %s, %s, 'completed', %s, %s, NOW())
|
||
|
|
RETURNING id, amount, description, status, created_at
|
||
|
|
""",
|
||
|
|
(from_id, to_id, type_t, float(montant),
|
||
|
|
libelle, banque_externe, numero_externe)
|
||
|
|
)
|
||
|
|
return serialiser_ligne(curseur.fetchone())
|