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