From 8320738acb71446a9edb6a20c5259922e4f51804 Mon Sep 17 00:00:00 2001 From: yolou Date: Fri, 27 Mar 2026 17:52:41 +0100 Subject: [PATCH] maj --- DragonBank/Rapport_Projet.md | 33 + DragonBank/backend/Dockerfile | 6 +- DragonBank/backend/app.py | 1213 +++++------------ DragonBank/backend/auth.py | 188 +++ DragonBank/backend/config.py | 113 ++ DragonBank/backend/database.py | 142 ++ DragonBank/backend/docker-compose.yml | 105 ++ DragonBank/backend/models/__init__.py | 0 DragonBank/backend/models/beneficiaire.py | 198 +++ DragonBank/backend/models/compte.py | 264 ++++ DragonBank/backend/models/simulateur.py | 113 ++ DragonBank/backend/models/transaction.py | 447 ++++++ DragonBank/backend/models/utilisateur.py | 243 ++++ DragonBank/backend/requirements.txt | 2 +- DragonBank/backend/services/auth_service.py | 83 ++ .../backend/services/beneficiaire_service.py | 54 + DragonBank/backend/services/compte_service.py | 68 + DragonBank/backend/services/stats_service.py | 41 + .../backend/services/transaction_service.py | 147 ++ .../backend/services/utilisateur_service.py | 133 ++ DragonBank/backend/validators.py | 181 +++ DragonBank/docker-compose.yml | 22 +- DragonBank/frontend/app.py | 148 ++ DragonBank/frontend/static/style.css | 999 +++++++++----- DragonBank/frontend/templates/base.html | 10 +- .../frontend/templates/beneficiaries.html | 75 + .../frontend/templates/open_account.html | 93 ++ DragonBank/frontend/templates/profile.html | 146 ++ DragonBank/frontend/templates/simulator.html | 321 +++++ .../frontend/templates/transactions.html | 195 +++ DragonBank/frontend/templates/transfer.html | 178 +++ DragonBank/interests/app.py | 537 +++++--- 32 files changed, 5113 insertions(+), 1385 deletions(-) create mode 100644 DragonBank/Rapport_Projet.md create mode 100644 DragonBank/backend/auth.py create mode 100644 DragonBank/backend/config.py create mode 100644 DragonBank/backend/database.py create mode 100644 DragonBank/backend/docker-compose.yml create mode 100644 DragonBank/backend/models/__init__.py create mode 100644 DragonBank/backend/models/beneficiaire.py create mode 100644 DragonBank/backend/models/compte.py create mode 100644 DragonBank/backend/models/simulateur.py create mode 100644 DragonBank/backend/models/transaction.py create mode 100644 DragonBank/backend/models/utilisateur.py create mode 100644 DragonBank/backend/services/auth_service.py create mode 100644 DragonBank/backend/services/beneficiaire_service.py create mode 100644 DragonBank/backend/services/compte_service.py create mode 100644 DragonBank/backend/services/stats_service.py create mode 100644 DragonBank/backend/services/transaction_service.py create mode 100644 DragonBank/backend/services/utilisateur_service.py create mode 100644 DragonBank/backend/validators.py create mode 100644 DragonBank/frontend/templates/beneficiaries.html create mode 100644 DragonBank/frontend/templates/open_account.html create mode 100644 DragonBank/frontend/templates/profile.html create mode 100644 DragonBank/frontend/templates/simulator.html create mode 100644 DragonBank/frontend/templates/transactions.html create mode 100644 DragonBank/frontend/templates/transfer.html diff --git a/DragonBank/Rapport_Projet.md b/DragonBank/Rapport_Projet.md new file mode 100644 index 0000000..65f71de --- /dev/null +++ b/DragonBank/Rapport_Projet.md @@ -0,0 +1,33 @@ +# 🐉 DragonBank - Rapport de Projet + +## 1. Description du Projet et des Conteneurs (Dockers) +Le projet DragonBank repose sur une architecture en micro-services conteneurisĂ©e Ă  l'aide de **Docker** et **Docker-Compose**. L'architecture comprend les 4 conteneurs suivants : + +- **`db` (Postgres)** : Base de donnĂ©es relationnelle persistante contenant les schĂ©mas, les tables, les configurations et l'historique des opĂ©rations. +- **`backend` (Python/Flask)** : Le "cerveau" de l'application. Cette API RESTful gĂšre toute la logique mĂ©tier (authentification, gestion des comptes, validations de virements, audits) en utilisant une architecture saine (*Controllers/Services/DAOs*). +- **`frontend` (Python/Flask + Jinja2)** : L'interface utilisateur web. Elle agit comme un client qui appelle l'API via des requĂȘtes HTTP JSON, et affiche les informations retournĂ©es dans une belle interface graphique dynamique propulsĂ©e par Bootstrap. +- **`interests` (Python)** : (Conteneur Bonus) Un conteneur "worker" autonome qui calcule et distribue le paiement asynchrone des intĂ©rĂȘts journaliers applicables (+3% ou +2%) aux comptes d'Ă©pargne (Livret A, Assurance Vie), sans jamais nĂ©cessiter d'intervention humaine et sans bloquer l'API principale de la banque. + +## 2. Fonctionnement de Chaque Application +- **Frontend** : L'utilisateur se connecte via `/login`. L'application authentifie l'utilisateur, rĂ©cupĂšre le token JWT de l'API et l'enregistre de maniĂšre sĂ©curisĂ©e dans la session locale (`session` en base 64 et signĂ©e). L'interface requiert ce token pour toutes les vues protĂ©gĂ©es afin de communiquer avec l'API (tableaux de bord, liste des comptes, demande de virement interne ou de virement intra-partenaires extĂ©rieurs). +- **Backend API** : L'API intercepte les requĂȘtes JSON. Elle vĂ©rifie l'intĂ©gritĂ© de l'identitĂ© du client via le token JWT, et applique les rĂšgles de validations bancaires (fonds suffisants pour valider l'opĂ©ration de virement, restrictions de l'IBAN bĂ©nĂ©ficiaire, etc.). La gestion transactionnelle de la base de donnĂ©es via nos services assure que l'architecture restera toujours consistante (propriĂ©tĂ©s ACID respectĂ©es). +- **Interests** : Service d'arriĂšre-plan avec un timer. Ce module exĂ©cute le SQL pour rĂ©cupĂ©rer les encours de l'Ă©pargne sur chaque client de DragonBank et gĂ©nĂšre un virement interne spĂ©cial pour matĂ©rialiser ce versement de profit au client de maniĂšre silencieuse. + +## 3. SchĂ©ma de Base de donnĂ©es +Le modĂšle relationnel (cf. `db/init.sql`) a Ă©tĂ© pensĂ© pour maximiser la sĂ©curitĂ© et la traçabilitĂ© de toutes les opĂ©rations de la banque : +- Les tables fondamentales : **`users`** (les clients, avec la civilitĂ© et le mot de passe hashĂ©), **`accounts`** (les comptes bancaires avec association au `user_id` et garantie par une contrainte de balance positive `>= 0`), **`beneficiaries`** (le carnet d'adresses bancaires d'un usager). +- La table **`transactions`** : L'historique immuable de chaque transfert d'argent (qui liste le `from_account`, `to_account`, et le type `virement_interne`, `externe` ou `interets`). +- Les tables annexes de traçabilitĂ© : **`audit_logs`** et **`sessions`** enregistrent toutes les activitĂ©s douteuses (tentatives d'authentifications rĂ©pĂ©tĂ©es, de connexions par d'autres IP, et expiration stricte par token session). + +## 4. Notions de SĂ©curitĂ© ImplĂ©mentĂ©es +Pour garantir la fiabilitĂ© de cet environnement acadĂ©mique, de nombreuses couches sĂ©curitaires ont Ă©tĂ© intĂ©grĂ©es : +1. **Mots de passe Fortement HachĂ©s** : ConservĂ©s cĂŽtĂ© base de donnĂ©es avec `bcrypt` en utilisant des sels d'entropie (pour se prĂ©munir totalement contre les attaques Rainbow Tables). +2. **SystĂšme de Jetons JSON (JWT)** : UtilisĂ©s pour vĂ©rifier la validitĂ© des accĂšs aux points d'API sans requĂ©rir le mot de passe sur les appels courants. +3. **Protection et VĂ©rifications des Profils** : Toute intention de changer un e-mail principal ou un mot de passe bancaire (depuis le nouvel Onglet Profil) exigera nĂ©cessairement de produire son dernier mot de passe actuel en date. +4. **Isolations Docker Secrets (Bonus)** : Pour empĂȘcher la divulgation des identifiants Root de la Base de DonnĂ©es au sein des variables ou du code source, Postgres s'initialise au lancement en lisant le mot de passe confinĂ© au sein d'un "Docker Secret" (`db_password.txt`). + +## 5. Explication de l'implĂ©mentation Docker-Compose +Le fichier `docker-compose.yml` construit le rĂ©seau total de maniĂšre hermĂ©tique et orchestrĂ©e : +- **Networks (Cloisonnement)** : Un rĂ©seau de Frontend (`dragonbank-frontend-net`) et un rĂ©seau de Backend (`dragonbank-backend-net`). Ce bridage fait que le Frontend a le droit de discuter avec l'API, mais empĂȘche physiquement le Frontend et potentiellement des attaques externes de ping la Base de DonnĂ©es directement. +- **Volumes** : Un volume permanent `postgres_data` permet un maintien et une persistance sans perte des transactions et liquiditĂ©s de la banque mĂȘme si le conteneur subit un crash inopinĂ© ou une mise Ă  jour. +- **Healthchecks (Bonus)** : La politique adoptĂ©e est "Fail Fast / Recover Quick". Les configurations Healthcheck imposent un ordre strict au dĂ©marrage absolu : l'interface Frontend s'interdira de dĂ©marrer et restera en pause tant que la route Health API de l'architecture Backend Python n'aura pas donnĂ© son aval (`curl -f /api/health`). diff --git a/DragonBank/backend/Dockerfile b/DragonBank/backend/Dockerfile index ee7af15..beb21e5 100644 --- a/DragonBank/backend/Dockerfile +++ b/DragonBank/backend/Dockerfile @@ -6,6 +6,8 @@ LABEL description="DragonBank Backend API" # Variables d'environnement ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +# Permet a Python de trouver les modules locaux (config, database, auth...) +ENV PYTHONPATH=/app # RĂ©pertoire de travail WORKDIR /app @@ -24,8 +26,8 @@ COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r requirements.txt -# Copie du code source -COPY app.py . +# Copie de tout le code source (app.py + modules + dossier models/) +COPY . . # CrĂ©ation d'un utilisateur non-root (sĂ©curitĂ©) RUN adduser --disabled-password --gecos '' appuser && \ diff --git a/DragonBank/backend/app.py b/DragonBank/backend/app.py index bb0e23d..0a794fc 100644 --- a/DragonBank/backend/app.py +++ b/DragonBank/backend/app.py @@ -1,946 +1,447 @@ """ DragonBank - Backend API ======================== -API REST pour la gestion bancaire complĂšte. +Point d'entree de l'API REST DragonBank. + +Ce fichier ne contient que les routes Flask. +Toute la logique est deleguee aux classes metier (Services) : + + config.py — Constantes et parametres + database.py — Connexion et serialisation + auth.py — JWT et protection des routes + validators.py — Validation des donnees entrantes + services/ + auth_service.py - Inscription et connexion + utilisateur_service.py - Profil utilisateur + compte_service.py - Gestion des comptes + beneficiaire_service.py- Gestion des beneficiaires + transaction_service.py - Virements et historique + stats_service.py - Statistiques tableau de bord + models/ + simulateur.py — Logique calcul interets composes + +Version : 3.0 (Refactoring Service Layer) """ -import os -import uuid -import decimal -from datetime import datetime, timedelta, timezone -from functools import wraps +import logging +from datetime import datetime, timezone -import jwt -import bcrypt -import psycopg2 -import psycopg2.extras from flask import Flask, request, jsonify from flask_cors import CORS -# ============================================ -# CONFIGURATION -# ============================================ +from config import CLE_SECRETE +from database import creer_connexion +from auth import token_requis +from validators import valider_parametres_simulateur + +from services.auth_service import AuthService +from services.utilisateur_service import UtilisateurService +from services.compte_service import CompteService +from services.beneficiaire_service import BeneficiaireService +from services.transaction_service import TransactionService +from services.stats_service import StatsService +from models.simulateur import Simulateur + + +# ============================================================ +# INITIALISATION +# ============================================================ + app = Flask(__name__) CORS(app) +app.config['SECRET_KEY'] = CLE_SECRETE -app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key') -DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://dragonadmin:dragonpass@db:5432/dragonbank') +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +journaliseur = logging.getLogger('dragonbank') -# ============================================ -# UTILITAIRES BASE DE DONNÉES -# ============================================ -def get_db_connection(): - """CrĂ©e une connexion Ă  la base de donnĂ©es PostgreSQL.""" - conn = psycopg2.connect( - DATABASE_URL, - cursor_factory=psycopg2.extras.RealDictCursor - ) - conn.autocommit = False - return conn +# Instances des services (sans etat, reutilisables) +auth_service = AuthService() +utilisateur_service = UtilisateurService() +compte_service = CompteService() +beneficiaire_service = BeneficiaireService() +transaction_service = TransactionService() +stats_service = StatsService() +simulateur = Simulateur() -def decimal_to_float(obj): - """Convertit les objets Decimal en float pour la sĂ©rialisation JSON.""" - if isinstance(obj, decimal.Decimal): - return float(obj) - if isinstance(obj, datetime): - return obj.isoformat() - if isinstance(obj, uuid.UUID): - return str(obj) - return obj +# ============================================================ +# SANTE +# ============================================================ - -def serialize_row(row): - """SĂ©rialise une ligne de rĂ©sultat de la DB.""" - if row is None: - return None - return {k: decimal_to_float(v) for k, v in dict(row).items()} - - -def serialize_rows(rows): - """SĂ©rialise plusieurs lignes.""" - return [serialize_row(row) for row in rows] - - -# ============================================ -# AUTHENTIFICATION JWT -# ============================================ -def generate_token(user_id, email): - """GĂ©nĂšre un token JWT.""" - payload = { - 'user_id': str(user_id), - 'email': email, - 'exp': datetime.now(timezone.utc) + timedelta(hours=24), - 'iat': datetime.now(timezone.utc) - } - return jwt.encode(payload, app.config['SECRET_KEY'], algorithm='HS256') - - -def token_required(f): - """DĂ©corateur pour protĂ©ger les routes avec JWT.""" - @wraps(f) - def decorated(*args, **kwargs): - token = None - - if 'Authorization' in request.headers: - auth_header = request.headers['Authorization'] - if auth_header.startswith('Bearer '): - token = auth_header.split(' ')[1] - - if not token: - return jsonify({'error': 'Token manquant'}), 401 - - try: - data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256']) - current_user_id = data['user_id'] - except jwt.ExpiredSignatureError: - return jsonify({'error': 'Token expirĂ©'}), 401 - except jwt.InvalidTokenError: - return jsonify({'error': 'Token invalide'}), 401 - - return f(current_user_id, *args, **kwargs) - return decorated - - -# ============================================ -# ROUTES - SANTÉ -# ============================================ @app.route('/api/health', methods=['GET']) -def health_check(): - """VĂ©rification de santĂ© de l'API.""" +def verification_sante(): + """Healthcheck pour Docker et load balancers.""" try: - conn = get_db_connection() - cur = conn.cursor() - cur.execute('SELECT 1') - cur.close() + conn = creer_connexion() + conn.cursor().execute('SELECT 1') conn.close() return jsonify({ - 'status': 'healthy', - 'service': 'DragonBank Backend API', - 'database': 'connected', + 'status': 'healthy', + 'service': 'DragonBank Backend API', + 'version': '3.0', + 'database': 'connected', 'timestamp': datetime.now(timezone.utc).isoformat() }), 200 except Exception as e: - return jsonify({ - 'status': 'unhealthy', - 'error': str(e) - }), 500 + journaliseur.error("Health check echoue : %s", str(e)) + return jsonify({'status': 'unhealthy', 'error': str(e)}), 500 -# ============================================ -# ROUTES - AUTHENTIFICATION -# ============================================ +# ============================================================ +# AUTHENTIFICATION +# ============================================================ + @app.route('/api/auth/register', methods=['POST']) -def register(): - """Inscription d'un nouvel utilisateur.""" - data = request.get_json() - - required_fields = ['email', 'password', 'first_name', 'last_name'] - for field in required_fields: - if field not in data or not data[field]: - return jsonify({'error': f'Le champ {field} est requis'}), 400 - - # Validation email - if '@' not in data['email'] or '.' not in data['email']: - return jsonify({'error': 'Email invalide'}), 400 - - # Validation mot de passe (min 6 caractĂšres) - if len(data['password']) < 6: - return jsonify({'error': 'Le mot de passe doit contenir au moins 6 caractĂšres'}), 400 - - # Hash du mot de passe - password_hash = bcrypt.hashpw( - data['password'].encode('utf-8'), - bcrypt.gensalt() - ).decode('utf-8') - - conn = get_db_connection() +def inscription(): + """Cree un nouveau compte utilisateur avec un compte courant de bienvenue.""" + donnees = request.get_json(silent=True) try: - cur = conn.cursor() - - # VĂ©rifier si l'email existe dĂ©jĂ  - cur.execute('SELECT id FROM users WHERE email = %s', (data['email'],)) - if cur.fetchone(): - return jsonify({'error': 'Cet email est dĂ©jĂ  utilisĂ©'}), 409 - - # CrĂ©er l'utilisateur - user_id = str(uuid.uuid4()) - cur.execute(''' - INSERT INTO users (id, email, password_hash, first_name, last_name, phone, address, date_of_birth) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, email, first_name, last_name, created_at - ''', ( - user_id, - data['email'], - password_hash, - data['first_name'], - data['last_name'], - data.get('phone', ''), - data.get('address', ''), - data.get('date_of_birth') - )) - user = cur.fetchone() - - # CrĂ©er automatiquement un compte courant - account_number = f"DRG{str(uuid.uuid4().int)[:13].zfill(13)}" - cur.execute(''' - INSERT INTO accounts (user_id, account_number, account_type, balance) - VALUES (%s, %s, 'courant', 100.00) - ''', (user_id, account_number)) - - conn.commit() - - # GĂ©nĂ©rer un token - token = generate_token(user_id, data['email']) - - # Log d'audit - cur.execute(''' - INSERT INTO audit_logs (user_id, action, details, ip_address) - VALUES (%s, %s, %s, %s) - ''', (user_id, 'REGISTER', '{"action": "user_registered"}', request.remote_addr)) - conn.commit() - - return jsonify({ - 'message': 'Inscription rĂ©ussie', - 'user': serialize_row(user), - 'token': token, - 'account_number': account_number - }), 201 - + resultat = auth_service.inscrire(donnees) + return jsonify(resultat), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 except Exception as e: - conn.rollback() - return jsonify({'error': f'Erreur lors de l\'inscription: {str(e)}'}), 500 - finally: - conn.close() + journaliseur.error("Erreur inscription : %s", str(e)) + return jsonify({'error': "Erreur interne lors de l'inscription"}), 500 @app.route('/api/auth/login', methods=['POST']) -def login(): - """Connexion d'un utilisateur.""" - data = request.get_json() - - if not data or 'email' not in data or 'password' not in data: - return jsonify({'error': 'Email et mot de passe requis'}), 400 - - conn = get_db_connection() +def connexion_utilisateur(): + """Authentifie un utilisateur et retourne un token JWT.""" + donnees = request.get_json(silent=True) try: - cur = conn.cursor() - cur.execute(''' - SELECT id, email, password_hash, first_name, last_name, is_active - FROM users WHERE email = %s - ''', (data['email'],)) - user = cur.fetchone() - - if not user: - return jsonify({'error': 'Email ou mot de passe incorrect'}), 401 - - if not user['is_active']: - return jsonify({'error': 'Compte dĂ©sactivĂ©'}), 403 - - # VĂ©rifier le mot de passe - if not bcrypt.checkpw( - data['password'].encode('utf-8'), - user['password_hash'].encode('utf-8') - ): - return jsonify({'error': 'Email ou mot de passe incorrect'}), 401 - - # Mettre Ă  jour le dernier login - cur.execute( - 'UPDATE users SET last_login = NOW() WHERE id = %s', - (str(user['id']),) - ) - - # Log d'audit - cur.execute(''' - INSERT INTO audit_logs (user_id, action, details, ip_address) - VALUES (%s, %s, %s, %s) - ''', (str(user['id']), 'LOGIN', '{"action": "user_login"}', request.remote_addr)) - - conn.commit() - - token = generate_token(user['id'], user['email']) - - return jsonify({ - 'message': 'Connexion rĂ©ussie', - 'token': token, - 'user': { - 'id': str(user['id']), - 'email': user['email'], - 'first_name': user['first_name'], - 'last_name': user['last_name'] - } - }), 200 - + resultat = auth_service.connecter(donnees) + return jsonify(resultat), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except PermissionError as e: + return jsonify({'error': str(e)}), 403 except Exception as e: - conn.rollback() - return jsonify({'error': f'Erreur de connexion: {str(e)}'}), 500 - finally: - conn.close() + journaliseur.error("Erreur connexion : %s", str(e)) + return jsonify({'error': 'Erreur interne lors de la connexion'}), 500 -# ============================================ -# ROUTES - PROFIL UTILISATEUR -# ============================================ +# ============================================================ +# PROFIL UTILISATEUR +# ============================================================ + @app.route('/api/user/profile', methods=['GET']) -@token_required -def get_profile(current_user_id): - """RĂ©cupĂšre le profil de l'utilisateur connectĂ©.""" - conn = get_db_connection() +@token_requis +def obtenir_profil(id_utilisateur_courant): + """Retourne le profil de l'utilisateur connecte.""" try: - cur = conn.cursor() - cur.execute(''' - SELECT id, email, first_name, last_name, phone, address, - date_of_birth, created_at, last_login - FROM users WHERE id = %s - ''', (current_user_id,)) - user = cur.fetchone() - - if not user: - return jsonify({'error': 'Utilisateur non trouvĂ©'}), 404 - - return jsonify({'user': serialize_row(user)}), 200 - finally: - conn.close() + utilisateur = utilisateur_service.obtenir_profil(id_utilisateur_courant) + return jsonify({'user': utilisateur}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 404 + except Exception as e: + return jsonify({'error': 'Erreur interne lors de la recuperation du profil'}), 500 -# ============================================ -# ROUTES - COMPTES BANCAIRES -# ============================================ +@app.route('/api/user/profile', methods=['PUT']) +@token_requis +def modifier_profil(id_utilisateur_courant): + """Met a jour les champs non sensibles du profil.""" + donnees = request.get_json(silent=True) + try: + mis_a_jour = utilisateur_service.modifier_profil(id_utilisateur_courant, donnees) + return jsonify({'message': 'Profil mis a jour', 'user': mis_a_jour}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + return jsonify({'error': 'Erreur lors de la mise a jour'}), 500 + + +@app.route('/api/user/security/password', methods=['PUT']) +@token_requis +def modifier_mot_de_passe(id_utilisateur_courant): + """Met a jour le mot de passe utilisateur (necessite le mot de passe actuel).""" + donnees = request.get_json(silent=True) + try: + utilisateur_service.changer_mot_de_passe(id_utilisateur_courant, donnees) + return jsonify({'message': 'Mot de passe mis a jour avec succes'}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except PermissionError as e: + return jsonify({'error': str(e)}), 403 + except Exception as e: + journaliseur.error("Erreur modification mot de passe : %s", str(e)) + return jsonify({'error': 'Erreur interne'}), 500 + + +@app.route('/api/user/security/email', methods=['PUT']) +@token_requis +def modifier_email(id_utilisateur_courant): + """Met a jour l'adresse email (necessite le mot de passe actuel).""" + donnees = request.get_json(silent=True) + try: + utilisateur_service.changer_email(id_utilisateur_courant, donnees) + return jsonify({'message': 'Email mis a jour avec succes. Veuillez vous reconnecter avec votre nouvel email.'}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except PermissionError as e: + return jsonify({'error': str(e)}), 403 + except Exception as e: + journaliseur.error("Erreur modification email : %s", str(e)) + return jsonify({'error': 'Erreur interne'}), 500 + + +# ============================================================ +# COMPTES BANCAIRES +# ============================================================ + @app.route('/api/accounts', methods=['GET']) -@token_required -def get_accounts(current_user_id): - """RĂ©cupĂšre tous les comptes de l'utilisateur.""" - conn = get_db_connection() +@token_requis +def obtenir_comptes(id_utilisateur_courant): + """Retourne la liste des comptes actifs.""" try: - cur = conn.cursor() - cur.execute(''' - SELECT id, account_number, account_type, balance, currency, - status, interest_rate, created_at - FROM accounts - WHERE user_id = %s AND status = 'active' - ORDER BY created_at ASC - ''', (current_user_id,)) - accounts = cur.fetchall() - - return jsonify({'accounts': serialize_rows(accounts)}), 200 - finally: - conn.close() + comptes = compte_service.obtenir_comptes(id_utilisateur_courant) + return jsonify({'accounts': comptes}), 200 + except Exception: + return jsonify({'error': 'Erreur lors de la recuperation des comptes'}), 500 @app.route('/api/accounts', methods=['POST']) -@token_required -def open_account(current_user_id): - """Ouverture d'un nouveau compte bancaire.""" - data = request.get_json() - account_type = data.get('account_type', 'courant') - - valid_types = ['courant', 'livret_a', 'assurance_vie'] - if account_type not in valid_types: - return jsonify({'error': f'Type de compte invalide. Types valides: {valid_types}'}), 400 - - # Taux d'intĂ©rĂȘt selon le type - interest_rates = { - 'courant': 0.0000, - 'livret_a': 0.0300, - 'assurance_vie': 0.0200 - } - - conn = get_db_connection() +@token_requis +def ouvrir_compte(id_utilisateur_courant): + """Ouvre un nouveau compte bancaire.""" + donnees = request.get_json(silent=True) try: - cur = conn.cursor() - - # VĂ©rifier qu'il n'a pas dĂ©jĂ  ce type de compte (sauf courant) - if account_type != 'courant': - cur.execute(''' - SELECT id FROM accounts - WHERE user_id = %s AND account_type = %s AND status = 'active' - ''', (current_user_id, account_type)) - if cur.fetchone(): - return jsonify({'error': f'Vous avez dĂ©jĂ  un compte {account_type}'}), 409 - - account_number = f"DRG{str(uuid.uuid4().int)[:13].zfill(13)}" - initial_balance = float(data.get('initial_deposit', 0)) - - cur.execute(''' - INSERT INTO accounts (user_id, account_number, account_type, balance, interest_rate) - VALUES (%s, %s, %s, %s, %s) - RETURNING id, account_number, account_type, balance, interest_rate, created_at - ''', ( - current_user_id, - account_number, - account_type, - initial_balance, - interest_rates[account_type] - )) - account = cur.fetchone() - - # Si dĂ©pĂŽt initial, crĂ©er une transaction - if initial_balance > 0: - cur.execute(''' - INSERT INTO transactions (to_account_id, transaction_type, amount, description, status, executed_at) - VALUES (%s, 'depot', %s, 'DĂ©pĂŽt initial Ă  l''ouverture du compte', 'completed', NOW()) - ''', (str(account['id']), initial_balance)) - - # Log d'audit - cur.execute(''' - INSERT INTO audit_logs (user_id, action, details, ip_address) - VALUES (%s, %s, %s, %s) - ''', ( - current_user_id, 'OPEN_ACCOUNT', - f'{{"account_type": "{account_type}", "account_number": "{account_number}"}}', - request.remote_addr - )) - - conn.commit() - - return jsonify({ - 'message': f'Compte {account_type} ouvert avec succĂšs', - 'account': serialize_row(account) - }), 201 - + compte = compte_service.ouvrir_compte(id_utilisateur_courant, donnees) + return jsonify({'message': 'Compte ouvert avec succes', 'account': compte}), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 if 'invalide' in str(e) else 409 except Exception as e: - conn.rollback() - return jsonify({'error': f'Erreur lors de l\'ouverture du compte: {str(e)}'}), 500 - finally: - conn.close() + journaliseur.error("Erreur ouverture compte : %s", str(e)) + return jsonify({'error': "Erreur lors de l'ouverture du compte"}), 500 -@app.route('/api/accounts/', methods=['GET']) -@token_required -def get_account_detail(current_user_id, account_id): - """DĂ©tails d'un compte spĂ©cifique.""" - conn = get_db_connection() +@app.route('/api/accounts/', methods=['GET']) +@token_requis +def obtenir_detail_compte(id_utilisateur_courant, id_compte): + """Retourne les details d'un compte specifique.""" try: - cur = conn.cursor() - cur.execute(''' - SELECT id, account_number, account_type, balance, currency, - status, interest_rate, created_at, updated_at - FROM accounts - WHERE id = %s AND user_id = %s - ''', (account_id, current_user_id)) - account = cur.fetchone() - - if not account: - return jsonify({'error': 'Compte non trouvĂ©'}), 404 - - return jsonify({'account': serialize_row(account)}), 200 - finally: - conn.close() + compte = compte_service.obtenir_detail_compte(id_utilisateur_courant, id_compte) + return jsonify({'account': compte}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 404 + except Exception: + return jsonify({'error': 'Erreur serveur'}), 500 -# ============================================ -# ROUTES - BÉNÉFICIAIRES -# ============================================ +@app.route('/api/accounts//history', methods=['GET']) +@token_requis +def historique_solde(id_utilisateur_courant, id_compte): + """Retourne l'evolution du solde pour le graphique.""" + try: + historique = compte_service.historique_solde(id_utilisateur_courant, id_compte) + return jsonify(historique), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 404 + except Exception: + return jsonify({'error': 'Erreur serveur'}), 500 + + +# ============================================================ +# BENEFICIAIRES +# ============================================================ + @app.route('/api/beneficiaries', methods=['GET']) -@token_required -def get_beneficiaries(current_user_id): - """Liste des bĂ©nĂ©ficiaires de l'utilisateur.""" - conn = get_db_connection() +@token_requis +def obtenir_beneficiaires(id_utilisateur_courant): + """Retourne la liste des beneficiaires.""" try: - cur = conn.cursor() - cur.execute(''' - SELECT id, beneficiary_name, bank_name, account_number, iban, bic, status, created_at - FROM beneficiaries - WHERE user_id = %s - ORDER BY created_at DESC - ''', (current_user_id,)) - beneficiaries = cur.fetchall() - - return jsonify({'beneficiaries': serialize_rows(beneficiaries)}), 200 - finally: - conn.close() + beneficiaires = beneficiaire_service.obtenir_beneficiaires(id_utilisateur_courant) + return jsonify({'beneficiaries': beneficiaires}), 200 + except Exception: + return jsonify({'error': 'Erreur serveur'}), 500 @app.route('/api/beneficiaries', methods=['POST']) -@token_required -def add_beneficiary(current_user_id): - """Ajout d'un nouveau bĂ©nĂ©ficiaire.""" - data = request.get_json() - - required_fields = ['beneficiary_name', 'account_number'] - for field in required_fields: - if field not in data or not data[field]: - return jsonify({'error': f'Le champ {field} est requis'}), 400 - - conn = get_db_connection() +@token_requis +def ajouter_beneficiaire(id_utilisateur_courant): + """Ajoute un nouveau beneficiaire.""" + donnees = request.get_json(silent=True) try: - cur = conn.cursor() - - # VĂ©rifier que le bĂ©nĂ©ficiaire n'existe pas dĂ©jĂ  - cur.execute(''' - SELECT id FROM beneficiaries - WHERE user_id = %s AND account_number = %s - ''', (current_user_id, data['account_number'])) - if cur.fetchone(): - return jsonify({'error': 'Ce bĂ©nĂ©ficiaire existe dĂ©jĂ '}), 409 - - # VĂ©rifier que ce n'est pas son propre compte - cur.execute(''' - SELECT id FROM accounts - WHERE user_id = %s AND account_number = %s - ''', (current_user_id, data['account_number'])) - if cur.fetchone(): - return jsonify({'error': 'Vous ne pouvez pas vous ajouter vous-mĂȘme comme bĂ©nĂ©ficiaire'}), 400 - - cur.execute(''' - INSERT INTO beneficiaries (user_id, beneficiary_name, bank_name, account_number, iban, bic) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id, beneficiary_name, bank_name, account_number, iban, bic, status, created_at - ''', ( - current_user_id, - data['beneficiary_name'], - data.get('bank_name', 'DragonBank'), - data['account_number'], - data.get('iban', ''), - data.get('bic', '') - )) - beneficiary = cur.fetchone() - - # Log d'audit - cur.execute(''' - INSERT INTO audit_logs (user_id, action, details, ip_address) - VALUES (%s, %s, %s, %s) - ''', ( - current_user_id, 'ADD_BENEFICIARY', - f'{{"beneficiary_name": "{data["beneficiary_name"]}"}}', - request.remote_addr - )) - - conn.commit() - - return jsonify({ - 'message': 'BĂ©nĂ©ficiaire ajoutĂ© avec succĂšs', - 'beneficiary': serialize_row(beneficiary) - }), 201 - + beneficiaire = beneficiaire_service.ajouter_beneficiaire(id_utilisateur_courant, donnees) + return jsonify({'message': 'Beneficiaire ajoute', 'beneficiary': beneficiaire}), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 if 'vous-meme' in str(e) else 409 except Exception as e: - conn.rollback() - return jsonify({'error': f'Erreur: {str(e)}'}), 500 - finally: - conn.close() + return jsonify({'error': "Erreur lors de l'ajout"}), 500 -@app.route('/api/beneficiaries/', methods=['DELETE']) -@token_required -def delete_beneficiary(current_user_id, beneficiary_id): - """Suppression d'un bĂ©nĂ©ficiaire.""" - conn = get_db_connection() +@app.route('/api/beneficiaries/', methods=['DELETE']) +@token_requis +def supprimer_beneficiaire(id_utilisateur_courant, id_beneficiaire): + """Supprime un beneficiaire.""" try: - cur = conn.cursor() - cur.execute(''' - DELETE FROM beneficiaries - WHERE id = %s AND user_id = %s - RETURNING id - ''', (beneficiary_id, current_user_id)) - deleted = cur.fetchone() - - if not deleted: - return jsonify({'error': 'BĂ©nĂ©ficiaire non trouvĂ©'}), 404 - - conn.commit() - return jsonify({'message': 'BĂ©nĂ©ficiaire supprimĂ©'}), 200 + beneficiaire_service.supprimer_beneficiaire(id_utilisateur_courant, id_beneficiaire) + return jsonify({'message': 'Beneficiaire supprime'}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 404 except Exception as e: - conn.rollback() - return jsonify({'error': str(e)}), 500 - finally: - conn.close() + return jsonify({'error': 'Erreur lors de la suppression'}), 500 -# ============================================ -# ROUTES - VIREMENTS / TRANSACTIONS -# ============================================ +# ============================================================ +# VIREMENTS +# ============================================================ + @app.route('/api/transfers/internal', methods=['POST']) -@token_required -def transfer_internal(current_user_id): - """ - Virement interne entre ses propres comptes. - """ - data = request.get_json() - - required_fields = ['from_account_id', 'to_account_id', 'amount'] - for field in required_fields: - if field not in data: - return jsonify({'error': f'Le champ {field} est requis'}), 400 - - amount = float(data['amount']) - if amount <= 0: - return jsonify({'error': 'Le montant doit ĂȘtre positif'}), 400 - - if data['from_account_id'] == data['to_account_id']: - return jsonify({'error': 'Les comptes source et destination doivent ĂȘtre diffĂ©rents'}), 400 - - conn = get_db_connection() +@token_requis +def virement_interne(id_utilisateur_courant): + """Virement entre les propres comptes de l'utilisateur.""" + donnees = request.get_json(silent=True) try: - cur = conn.cursor() - - # VĂ©rifier compte source - cur.execute(''' - SELECT id, balance, account_number, account_type FROM accounts - WHERE id = %s AND user_id = %s AND status = 'active' - ''', (data['from_account_id'], current_user_id)) - from_account = cur.fetchone() - - if not from_account: - return jsonify({'error': 'Compte source non trouvĂ©'}), 404 - - if float(from_account['balance']) < amount: - return jsonify({'error': 'Solde insuffisant'}), 400 - - # VĂ©rifier compte destination - cur.execute(''' - SELECT id, account_number, account_type FROM accounts - WHERE id = %s AND user_id = %s AND status = 'active' - ''', (data['to_account_id'], current_user_id)) - to_account = cur.fetchone() - - if not to_account: - return jsonify({'error': 'Compte destination non trouvĂ©'}), 404 - - # Effectuer le virement - cur.execute( - 'UPDATE accounts SET balance = balance - %s WHERE id = %s', - (amount, data['from_account_id']) - ) - cur.execute( - 'UPDATE accounts SET balance = balance + %s WHERE id = %s', - (amount, data['to_account_id']) - ) - - # Enregistrer la transaction - description = data.get('description', - f"Virement interne {from_account['account_type']} → {to_account['account_type']}") - cur.execute(''' - INSERT INTO transactions - (from_account_id, to_account_id, transaction_type, amount, description, status, executed_at) - VALUES (%s, %s, 'virement_interne', %s, %s, 'completed', NOW()) - RETURNING id, amount, description, status, created_at - ''', (data['from_account_id'], data['to_account_id'], amount, description)) - transaction = cur.fetchone() - - # Log d'audit - cur.execute(''' - INSERT INTO audit_logs (user_id, action, details, ip_address) - VALUES (%s, %s, %s, %s) - ''', ( - current_user_id, 'TRANSFER_INTERNAL', - f'{{"amount": {amount}, "from": "{from_account["account_number"]}", "to": "{to_account["account_number"]}"}}', - request.remote_addr - )) - - conn.commit() - - return jsonify({ - 'message': 'Virement interne effectuĂ© avec succĂšs', - 'transaction': serialize_row(transaction) - }), 200 - + transaction = transaction_service.virement_interne(id_utilisateur_courant, donnees) + return jsonify({'message': 'Virement effectue', 'transaction': transaction}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 if 'introuvable' not in str(e) else 404 except Exception as e: - conn.rollback() - return jsonify({'error': f'Erreur lors du virement: {str(e)}'}), 500 - finally: - conn.close() + journaliseur.error("Erreur virement interne : %s", str(e)) + return jsonify({'error': 'Erreur lors du virement'}), 500 @app.route('/api/transfers/person', methods=['POST']) -@token_required -def transfer_to_person(current_user_id): - """ - Virement vers une autre personne (bĂ©nĂ©ficiaire) dans DragonBank. - """ - data = request.get_json() - - required_fields = ['from_account_id', 'beneficiary_id', 'amount'] - for field in required_fields: - if field not in data: - return jsonify({'error': f'Le champ {field} est requis'}), 400 - - amount = float(data['amount']) - if amount <= 0: - return jsonify({'error': 'Le montant doit ĂȘtre positif'}), 400 - - conn = get_db_connection() +@token_requis +def virement_beneficiaire(id_utilisateur_courant): + """Virement vers un beneficiaire enregistre.""" + donnees = request.get_json(silent=True) try: - cur = conn.cursor() - - # VĂ©rifier compte source - cur.execute(''' - SELECT id, balance, account_number FROM accounts - WHERE id = %s AND user_id = %s AND status = 'active' - ''', (data['from_account_id'], current_user_id)) - from_account = cur.fetchone() - - if not from_account: - return jsonify({'error': 'Compte source non trouvĂ©'}), 404 - - if float(from_account['balance']) < amount: - return jsonify({'error': 'Solde insuffisant'}), 400 - - # VĂ©rifier le bĂ©nĂ©ficiaire - cur.execute(''' - SELECT id, beneficiary_name, account_number, bank_name - FROM beneficiaries - WHERE id = %s AND user_id = %s AND status = 'approved' - ''', (data['beneficiary_id'], current_user_id)) - beneficiary = cur.fetchone() - - if not beneficiary: - return jsonify({'error': 'BĂ©nĂ©ficiaire non trouvĂ© ou non approuvĂ©'}), 404 - - # Chercher le compte destination dans DragonBank - cur.execute(''' - SELECT id, account_number FROM accounts - WHERE account_number = %s AND status = 'active' - ''', (beneficiary['account_number'],)) - to_account = cur.fetchone() - - if not to_account: - return jsonify({'error': 'Compte bĂ©nĂ©ficiaire introuvable dans DragonBank'}), 404 - - # Effectuer le virement - cur.execute( - 'UPDATE accounts SET balance = balance - %s WHERE id = %s', - (amount, data['from_account_id']) - ) - cur.execute( - 'UPDATE accounts SET balance = balance + %s WHERE id = %s', - (amount, str(to_account['id'])) - ) - - description = data.get('description', - f"Virement Ă  {beneficiary['beneficiary_name']}") - cur.execute(''' - INSERT INTO transactions - (from_account_id, to_account_id, transaction_type, amount, description, status, executed_at) - VALUES (%s, %s, 'virement_entre_personnes', %s, %s, 'completed', NOW()) - RETURNING id, amount, description, status, created_at - ''', (data['from_account_id'], str(to_account['id']), amount, description)) - transaction = cur.fetchone() - - # Log d'audit - cur.execute(''' - INSERT INTO audit_logs (user_id, action, details, ip_address) - VALUES (%s, %s, %s, %s) - ''', ( - current_user_id, 'TRANSFER_PERSON', - f'{{"amount": {amount}, "beneficiary": "{beneficiary["beneficiary_name"]}"}}', - request.remote_addr - )) - - conn.commit() - - return jsonify({ - 'message': f'Virement de {amount}€ Ă  {beneficiary["beneficiary_name"]} effectuĂ©', - 'transaction': serialize_row(transaction) - }), 200 - + transaction = transaction_service.virement_beneficiaire(id_utilisateur_courant, donnees) + return jsonify({'message': 'Virement effectue', 'transaction': transaction}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 if 'introuvable' not in str(e) else 404 except Exception as e: - conn.rollback() - return jsonify({'error': f'Erreur lors du virement: {str(e)}'}), 500 - finally: - conn.close() + return jsonify({'error': 'Erreur lors du virement'}), 500 @app.route('/api/transfers/external', methods=['POST']) -@token_required -def transfer_external(current_user_id): - """ - Virement depuis/vers une autre banque (simulĂ©). - """ - data = request.get_json() - - required_fields = ['account_id', 'amount', 'external_bank_name', 'external_account_number', 'direction'] - for field in required_fields: - if field not in data: - return jsonify({'error': f'Le champ {field} est requis'}), 400 - - amount = float(data['amount']) - if amount <= 0: - return jsonify({'error': 'Le montant doit ĂȘtre positif'}), 400 - - direction = data['direction'] # 'incoming' ou 'outgoing' - - conn = get_db_connection() +@token_requis +def virement_externe(id_utilisateur_courant): + """Virement simule depuis/vers une banque externe.""" + donnees = request.get_json(silent=True) try: - cur = conn.cursor() - - # VĂ©rifier le compte - cur.execute(''' - SELECT id, balance, account_number FROM accounts - WHERE id = %s AND user_id = %s AND status = 'active' - ''', (data['account_id'], current_user_id)) - account = cur.fetchone() - - if not account: - return jsonify({'error': 'Compte non trouvĂ©'}), 404 - - if direction == 'outgoing' and float(account['balance']) < amount: - return jsonify({'error': 'Solde insuffisant'}), 400 - - # Effectuer l'opĂ©ration - if direction == 'outgoing': - cur.execute( - 'UPDATE accounts SET balance = balance - %s WHERE id = %s', - (amount, data['account_id']) - ) - description = f"Virement sortant vers {data['external_bank_name']} - {data['external_account_number']}" - from_id = data['account_id'] - to_id = None - else: # incoming - cur.execute( - 'UPDATE accounts SET balance = balance + %s WHERE id = %s', - (amount, data['account_id']) - ) - description = f"Virement entrant depuis {data['external_bank_name']} - {data['external_account_number']}" - from_id = None - to_id = data['account_id'] - - description = data.get('description', description) - - cur.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, 'virement_externe', %s, %s, 'completed', %s, %s, NOW()) - RETURNING id, amount, description, status, created_at - ''', (from_id, to_id, amount, description, - data['external_bank_name'], data['external_account_number'])) - transaction = cur.fetchone() - - # Log d'audit - cur.execute(''' - INSERT INTO audit_logs (user_id, action, details, ip_address) - VALUES (%s, %s, %s, %s) - ''', ( - current_user_id, 'TRANSFER_EXTERNAL', - f'{{"amount": {amount}, "direction": "{direction}", "bank": "{data["external_bank_name"]}"}}', - request.remote_addr - )) - - conn.commit() - + transaction, direction = transaction_service.virement_externe(id_utilisateur_courant, donnees) + sens = 'sortant' if direction == 'outgoing' else 'entrant' return jsonify({ - 'message': f'Virement externe {"sortant" if direction == "outgoing" else "entrant"} de {amount}€ effectuĂ©', - 'transaction': serialize_row(transaction) + 'message': 'Virement externe ' + sens + ' effectue', + 'transaction': transaction }), 200 - + except ValueError as e: + return jsonify({'error': str(e)}), 400 if 'introuvable' not in str(e) else 404 except Exception as e: - conn.rollback() - return jsonify({'error': f'Erreur: {str(e)}'}), 500 - finally: - conn.close() + return jsonify({'error': 'Erreur lors du virement externe'}), 500 -# ============================================ -# ROUTES - HISTORIQUE DES TRANSACTIONS -# ============================================ +# ============================================================ +# TRANSACTIONS +# ============================================================ + @app.route('/api/transactions', methods=['GET']) -@token_required -def get_transactions(current_user_id): - """RĂ©cupĂšre l'historique des transactions de l'utilisateur.""" - limit = request.args.get('limit', 50, type=int) - account_id = request.args.get('account_id', None) - - conn = get_db_connection() +@token_requis +def obtenir_transactions(id_utilisateur_courant): + """Historique des transactions avec filtres optionnels.""" + id_compte = request.args.get('account_id') + type_transaction = request.args.get('type') + limite = request.args.get('limit', 50, type=int) + try: - cur = conn.cursor() - - if account_id: - cur.execute(''' - 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 - WHERE (t.from_account_id = %s OR t.to_account_id = %s) - ORDER BY t.created_at DESC - LIMIT %s - ''', (account_id, account_id, limit)) - else: - cur.execute(''' - 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 - WHERE fa.user_id = %s OR ta.user_id = %s - ORDER BY t.created_at DESC - LIMIT %s - ''', (current_user_id, current_user_id, limit)) - - transactions = cur.fetchall() - - return jsonify({'transactions': serialize_rows(transactions)}), 200 - finally: - conn.close() + transactions = transaction_service.obtenir_transactions( + id_utilisateur_courant, id_compte, type_transaction, limite + ) + return jsonify({'transactions': transactions, 'count': len(transactions)}), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 404 + except Exception: + return jsonify({'error': 'Erreur serveur'}), 500 -# ============================================ -# ROUTES - STATISTIQUES -# ============================================ +@app.route('/api/transactions/export', methods=['GET']) +@token_requis +def exporter_csv(id_utilisateur_courant): + """Export CSV de l'historique des transactions.""" + id_compte = request.args.get('account_id') + try: + csv_response = transaction_service.exporter_csv(id_utilisateur_courant, id_compte) + return csv_response + except ValueError as e: + return jsonify({'error': str(e)}), 404 + except Exception: + return jsonify({'error': 'Erreur serveur'}), 500 + + +# ============================================================ +# STATISTIQUES +# ============================================================ + @app.route('/api/stats', methods=['GET']) -@token_required -def get_stats(current_user_id): - """Statistiques du compte utilisateur.""" - conn = get_db_connection() +@token_requis +def obtenir_statistiques(id_utilisateur_courant): + """Tableau de bord statistique de l'utilisateur.""" try: - cur = conn.cursor() - - # Solde total - cur.execute(''' - SELECT COALESCE(SUM(balance), 0) as total_balance, - COUNT(*) as total_accounts - FROM accounts - WHERE user_id = %s AND status = 'active' - ''', (current_user_id,)) - balance_stats = cur.fetchone() - - # Nombre de transactions ce mois - cur.execute(''' - SELECT COUNT(*) as monthly_transactions, - COALESCE(SUM(CASE WHEN fa.user_id = %s THEN amount ELSE 0 END), 0) as total_sent, - COALESCE(SUM(CASE WHEN ta.user_id = %s THEN amount ELSE 0 END), 0) as total_received - 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' - ''', (current_user_id, current_user_id, current_user_id, current_user_id)) - transaction_stats = cur.fetchone() - - # Nombre de bĂ©nĂ©ficiaires - cur.execute(''' - SELECT COUNT(*) as total_beneficiaries - FROM beneficiaries WHERE user_id = %s - ''', (current_user_id,)) - ben_stats = cur.fetchone() - - return jsonify({ - 'stats': { - 'total_balance': decimal_to_float(balance_stats['total_balance']), - 'total_accounts': balance_stats['total_accounts'], - 'monthly_transactions': transaction_stats['monthly_transactions'], - 'total_sent': decimal_to_float(transaction_stats['total_sent']), - 'total_received': decimal_to_float(transaction_stats['total_received']), - 'total_beneficiaries': ben_stats['total_beneficiaries'] - } - }), 200 - finally: - conn.close() + stats = stats_service.obtenir_statistiques(id_utilisateur_courant) + return jsonify({'stats': stats}), 200 + except Exception: + return jsonify({'error': 'Erreur serveur'}), 500 -# ============================================ -# DÉMARRAGE -# ============================================ +# ============================================================ +# SIMULATEUR D'EPARGNE +# ============================================================ + +@app.route('/api/simulator', methods=['POST']) +@token_requis +def simuler_epargne(id_utilisateur_courant): + """Simulation de croissance d'epargne par interets composes.""" + donnees = request.get_json(silent=True) + try: + if not donnees: + raise ValueError("Donnees JSON manquantes") + + capital = float(donnees.get('capital_initial', 0)) + taux = float(donnees.get('taux_annuel', 0)) + duree = int(donnees.get('duree_annees', 1)) + versement = float(donnees.get('versement_mensuel', 0)) + + valider_parametres_simulateur(capital, taux, duree, versement) + + resultat = simulateur.simuler(capital, taux, duree, versement) + return jsonify(resultat), 200 + + except (TypeError, ValueError) as e: + return jsonify({'error': str(e)}), 400 + + +# ============================================================ +# GESTION DES ERREURS HTTP +# ============================================================ + +@app.errorhandler(404) +def erreur_404(e): + return jsonify({'error': 'Route introuvable'}), 404 + +@app.errorhandler(405) +def erreur_405(e): + return jsonify({'error': 'Methode HTTP non autorisee'}), 405 + +@app.errorhandler(500) +def erreur_500(e): + journaliseur.error("Erreur interne : %s", str(e)) + return jsonify({'error': 'Erreur interne du serveur'}), 500 + + +# ============================================================ +# POINT D'ENTREE +# ============================================================ + if __name__ == '__main__': - print("🐉 DragonBank Backend API starting...") - print(f"📡 Database: {DATABASE_URL.split('@')[1] if '@' in DATABASE_URL else 'configured'}") - app.run(host='0.0.0.0', port=5000, debug=False) \ No newline at end of file + from config import URL_BASE_DE_DONNEES + print("DragonBank Backend API v3.0 - demarrage...") + print("Base : " + (URL_BASE_DE_DONNEES.split('@')[1] if '@' in URL_BASE_DE_DONNEES else 'configuree')) + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/DragonBank/backend/auth.py b/DragonBank/backend/auth.py new file mode 100644 index 0000000..2a308f9 --- /dev/null +++ b/DragonBank/backend/auth.py @@ -0,0 +1,188 @@ +""" +DragonBank - Authentification +============================== +Gere la generation et la verification des tokens JWT, +ainsi que le decorateur de protection des routes Flask. + +Version : 3.0 +""" + +import uuid +import logging +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from functools import wraps + +import jwt +import bcrypt +from flask import request +from flask import jsonify +from flask import current_app + +from config import DUREE_TOKEN_HEURES, COUT_HACHAGE_BCRYPT + +journaliseur = logging.getLogger('dragonbank.auth') + + +# ============================================================ +# UTILITAIRES IP +# ============================================================ + +def obtenir_ip_client(): + """ + Retourne l'adresse IP reelle du client. + + Lorsque l'application est derriere un reverse proxy (Nginx, Traefik), + l'en-tete X-Forwarded-For contient la liste des IPs traversees. + La premiere est celle du client d'origine. + + Returns: + str: L'adresse IP du client. + """ + entete = request.headers.get('X-Forwarded-For') + if entete: + return entete.split(',')[0].strip() + return request.remote_addr + + +# ============================================================ +# GESTION DES MOTS DE PASSE +# ============================================================ + +def hacher_mot_de_passe(mot_de_passe_clair): + """ + Hache un mot de passe avec bcrypt. + + Args: + mot_de_passe_clair (str): Le mot de passe en clair. + + Returns: + str: Le hash bcrypt encode en UTF-8. + """ + sel = bcrypt.gensalt(rounds=COUT_HACHAGE_BCRYPT) + return bcrypt.hashpw( + mot_de_passe_clair.encode('utf-8'), + sel + ).decode('utf-8') + + +def verifier_mot_de_passe(mot_de_passe_clair, hash_stocke): + """ + Verifie un mot de passe contre son hash bcrypt stocke. + + Args: + mot_de_passe_clair (str): Le mot de passe en clair soumis. + hash_stocke (str) : Le hash bcrypt stocke en base. + + Returns: + bool: True si le mot de passe correspond, False sinon. + """ + return bcrypt.checkpw( + mot_de_passe_clair.encode('utf-8'), + hash_stocke.encode('utf-8') + ) + + +def simuler_verification_bcrypt(): + """ + Effectue un hachage bcrypt fictif pour consommer du temps CPU. + + Utilise pour uniformiser le temps de reponse lors d'un echec + de connexion, qu'il s'agisse d'un email inconnu ou d'un mauvais + mot de passe (contre-mesure aux attaques temporelles). + """ + hacher_mot_de_passe('dummy') + + +# ============================================================ +# TOKENS JWT +# ============================================================ + +def generer_token(id_utilisateur, email): + """ + Genere un token JWT signe pour authentifier un utilisateur. + + Charge utile du token : + - user_id : UUID de l'utilisateur. + - email : Adresse email. + - iat : Date de creation (issued at). + - exp : Date d'expiration. + - jti : Identifiant unique du token (JWT ID). + + Args: + id_utilisateur (str): UUID de l'utilisateur. + email (str) : Adresse email. + + Returns: + str: Le token JWT encode et signe. + """ + maintenant = datetime.now(timezone.utc) + charge_utile = { + 'user_id': str(id_utilisateur), + 'email': email, + 'iat': maintenant, + 'exp': maintenant + timedelta(hours=DUREE_TOKEN_HEURES), + 'jti': str(uuid.uuid4()) + } + return jwt.encode( + charge_utile, + current_app.config['SECRET_KEY'], + algorithm='HS256' + ) + + +# ============================================================ +# DECORATEUR DE PROTECTION DES ROUTES +# ============================================================ + +def token_requis(fonction): + """ + Decorateur protegeant une route Flask par verification du token JWT. + + Extrait le token depuis l'en-tete Authorization (format Bearer), + le valide, puis injecte l'identifiant de l'utilisateur comme + premier argument de la fonction decoree. + + Usage : + @app.route('/api/exemple') + @token_requis + def ma_route(id_utilisateur_courant): + ... + + Returns: + 401: Token manquant, expire ou invalide. + """ + @wraps(fonction) + def enveloppe(*args, **kwargs): + + # --- Extraction du token --- + token = None + entete = request.headers.get('Authorization', '') + if entete.startswith('Bearer '): + token = entete.split(' ', 1)[1].strip() + + if not token: + journaliseur.warning("Acces sans token depuis %s", obtenir_ip_client()) + return jsonify({'error': "Token d'authentification manquant"}), 401 + + # --- Verification et decodage --- + try: + charge = jwt.decode( + token, + current_app.config['SECRET_KEY'], + algorithms=['HS256'] + ) + id_utilisateur_courant = charge['user_id'] + + except jwt.ExpiredSignatureError: + journaliseur.info("Token expire depuis %s", obtenir_ip_client()) + return jsonify({'error': 'Session expiree, veuillez vous reconnecter'}), 401 + + except jwt.InvalidTokenError as erreur: + journaliseur.warning("Token invalide depuis %s : %s", obtenir_ip_client(), str(erreur)) + return jsonify({'error': 'Token invalide'}), 401 + + return fonction(id_utilisateur_courant, *args, **kwargs) + + return enveloppe diff --git a/DragonBank/backend/config.py b/DragonBank/backend/config.py new file mode 100644 index 0000000..a3d985b --- /dev/null +++ b/DragonBank/backend/config.py @@ -0,0 +1,113 @@ +""" +DragonBank - Configuration +=========================== +Centralise toutes les constantes et parametres de l'application. +Toutes les valeurs configurables sont recuperees depuis les variables +d'environnement afin de ne jamais stocker de donnees sensibles +dans le code source (bonne pratique Docker / 12-factor app). + +Version : 3.0 +""" + +import os +import decimal + + +# ============================================================ +# SECURITE +# ============================================================ + +# Cle secrete pour la signature des tokens JWT. +# OBLIGATOIRE en production via variable d'environnement. +CLE_SECRETE = os.environ.get('SECRET_KEY', 'dev-secret-key-changez-moi-en-production') + +# Duree de validite d'un token JWT en heures. +DUREE_TOKEN_HEURES = int(os.environ.get('JWT_EXPIRATION_HOURS', 24)) + +# Nombre de rounds bcrypt pour le hachage des mots de passe. +# 12 = bon equilibre securite / performance. +COUT_HACHAGE_BCRYPT = 12 + + +# ============================================================ +# BASE DE DONNEES +# ============================================================ + +# URL de connexion PostgreSQL. +# Format : postgresql://utilisateur:motdepasse@hote:port/base +# URL de connexion PostgreSQL. +# Format : postgresql://utilisateur:motdepasse@hote:port/base +def _get_secure_db_url(): + ext_url = os.environ.get('DATABASE_URL') + if ext_url: return ext_url + + user = os.environ.get('POSTGRES_USER', 'dragonadmin') + host = os.environ.get('POSTGRES_HOST', 'db') + port = os.environ.get('POSTGRES_PORT', '5432') + dbname = os.environ.get('POSTGRES_DB', 'dragonbank') + + password = 'dragonpass' + secret_path = os.environ.get('POSTGRES_PASSWORD_FILE', '/run/secrets/db_password') + if os.path.exists(secret_path): + with open(secret_path, 'r') as f: + password = f.read().strip() + + return f"postgresql://{user}:{password}@{host}:{port}/{dbname}" + +URL_BASE_DE_DONNEES = _get_secure_db_url() + + +# ============================================================ +# REGLES METIER - VIREMENTS +# ============================================================ + +# Montant minimum autorise pour un virement (en euros). +MONTANT_MINIMUM_VIREMENT = decimal.Decimal('0.01') + +# Montant maximum autorise pour un virement (en euros). +MONTANT_MAXIMUM_VIREMENT = decimal.Decimal('50000.00') + +# Solde de bienvenue credite a l'ouverture du premier compte courant. +SOLDE_BIENVENUE = decimal.Decimal('100.00') + + +# ============================================================ +# REGLES METIER - COMPTES +# ============================================================ + +# Taux d'interet par cycle selon le type de compte. +TAUX_INTERETS = { + 'courant': decimal.Decimal('0.0000'), # 0 % - pas d'interet + 'livret_a': decimal.Decimal('0.0300'), # 3 % - taux reglemente + 'assurance_vie': decimal.Decimal('0.0200'), # 2 % - fonds euros +} + +# Types de comptes dont l'unicite est imposee par la reglementation. +# Un client ne peut detenir qu'un seul compte de ces types. +TYPES_COMPTE_UNIQUES = ('livret_a', 'assurance_vie') + +# Champs du profil que l'utilisateur est autorise a modifier. +# Tous les autres champs (email, mot de passe, is_active) sont proteges. +CHAMPS_PROFIL_MODIFIABLES = ['first_name', 'last_name', 'phone', 'address'] + + +# ============================================================ +# REGLES METIER - TRANSACTIONS +# ============================================================ + +# Nombre de transactions retournees par defaut par l'historique. +LIMITE_TRANSACTIONS_DEFAUT = 50 + +# Nombre maximum de transactions retournables en une seule requete. +LIMITE_TRANSACTIONS_MAX = 200 + + +# ============================================================ +# SIMULATEUR D'EPARGNE +# ============================================================ + +# Duree minimale de simulation en annees. +SIMULATION_DUREE_MIN = 1 + +# Duree maximale de simulation en annees. +SIMULATION_DUREE_MAX = 40 diff --git a/DragonBank/backend/database.py b/DragonBank/backend/database.py new file mode 100644 index 0000000..8272c7a --- /dev/null +++ b/DragonBank/backend/database.py @@ -0,0 +1,142 @@ +""" +DragonBank - Utilitaires Base de Donnees +========================================= +Gere la connexion a PostgreSQL et la serialisation +des resultats de requetes en types JSON-compatibles. + +Version : 3.0 +""" + +import uuid +import decimal +import logging +from datetime import datetime + +import psycopg2 +import psycopg2.extras + +from config import URL_BASE_DE_DONNEES + +journaliseur = logging.getLogger('dragonbank.database') + + +# ============================================================ +# CONNEXION +# ============================================================ + +def creer_connexion(): + """ + Cree et retourne une connexion active a la base de donnees PostgreSQL. + + Utilise RealDictCursor pour que les resultats soient des + dictionnaires (acces par nom de colonne) plutot que des tuples. + + L'autocommit est desactive : chaque route doit appeler + commit() ou rollback() explicitement. + + Returns: + psycopg2.connection: Connexion avec autocommit desactive. + + Raises: + psycopg2.OperationalError: Si la connexion echoue. + """ + connexion = psycopg2.connect( + URL_BASE_DE_DONNEES, + cursor_factory=psycopg2.extras.RealDictCursor + ) + connexion.autocommit = False + return connexion + + +# ============================================================ +# SERIALISATION JSON +# ============================================================ + +def serialiser_valeur(valeur): + """ + Convertit une valeur PostgreSQL en type serialisable JSON. + + Certains types retournes par psycopg2 (Decimal, datetime, UUID) + ne sont pas nativement serialisables par json.dumps(). + + Args: + valeur: La valeur a convertir. + + Returns: + float : si la valeur est un Decimal. + str : si la valeur est un datetime ou un UUID. + valeur : inchangee pour tous les autres types. + """ + if isinstance(valeur, decimal.Decimal): + return float(valeur) + if isinstance(valeur, datetime): + return valeur.isoformat() + if isinstance(valeur, uuid.UUID): + return str(valeur) + return valeur + + +def serialiser_ligne(ligne): + """ + Serialise une ligne de resultat PostgreSQL en dictionnaire Python. + + Args: + ligne: Une RealDictRow psycopg2, ou None. + + Returns: + dict : Toutes les colonnes avec leurs valeurs converties. + None : Si la ligne est None. + """ + if ligne is None: + return None + + resultat = {} + for cle, valeur in dict(ligne).items(): + resultat[cle] = serialiser_valeur(valeur) + return resultat + + +def serialiser_lignes(lignes): + """ + Serialise une liste de lignes de resultat PostgreSQL. + + Args: + lignes: Liste de RealDictRow psycopg2. + + Returns: + list[dict]: Liste de dictionnaires serialises. + """ + resultat = [] + for ligne in lignes: + resultat.append(serialiser_ligne(ligne)) + return resultat + + +# ============================================================ +# AUDIT +# ============================================================ + +def enregistrer_audit(curseur, id_utilisateur, action, details, adresse_ip): + """ + Insere une entree dans la table des journaux d'audit. + + Trace toutes les actions sensibles (connexion, virement, ouverture + de compte) pour la conformite reglementaire (RGPD, DSP2). + + Args: + curseur : Curseur de base de donnees actif. + id_utilisateur : UUID de l'utilisateur. + action (str) : Code de l'action (ex: 'LOGIN', 'TRANSFER_INTERNAL'). + details (dict) : Informations complementaires en JSONB. + adresse_ip (str): IP du client. + + Note: + Ne commit pas. La validation est a la charge de l'appelant. + """ + curseur.execute( + """ + INSERT INTO audit_logs (user_id, action, details, ip_address) + VALUES (%s, %s, %s::jsonb, %s) + """, + (id_utilisateur, action, psycopg2.extras.Json(details), adresse_ip) + ) diff --git a/DragonBank/backend/docker-compose.yml b/DragonBank/backend/docker-compose.yml new file mode 100644 index 0000000..cfbf9f4 --- /dev/null +++ b/DragonBank/backend/docker-compose.yml @@ -0,0 +1,105 @@ +services: + db: + build: + context: ./db + dockerfile: Dockerfile + container_name: dragonbank-db + environment: + POSTGRES_DB: dragonbank + POSTGRES_USER: dragonadmin + POSTGRES_PASSWORD: dragonpass + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - dragonbank-backend-net + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dragonadmin -d dragonbank"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: dragonbank-backend + environment: + DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank + SECRET_KEY: dragonbank-super-secret-key-2024 + FLASK_ENV: production + depends_on: + db: + condition: service_healthy + networks: + - dragonbank-backend-net + - dragonbank-frontend-net + ports: + - "5000:5000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 20s + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: dragonbank-frontend + environment: + BACKEND_URL: http://backend:5000 + SECRET_KEY: frontend-secret-key-2024 + depends_on: + backend: + condition: service_healthy + networks: + - dragonbank-frontend-net + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 15s + restart: unless-stopped + + interests: + build: + context: ./interests + dockerfile: Dockerfile + container_name: dragonbank-interests + environment: + DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank + INTEREST_RATE_LIVRET_A: 0.03 + INTEREST_RATE_ASSURANCE_VIE: 0.02 + INTERVAL_SECONDS: 86400 + depends_on: + db: + condition: service_healthy + networks: + - dragonbank-backend-net + healthcheck: + test: ["CMD", "python", "-c", "import psycopg2; psycopg2.connect('postgresql://dragonadmin:dragonpass@db:5432/dragonbank')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + restart: unless-stopped + +volumes: + postgres_data: + driver: local + +networks: + dragonbank-backend-net: + driver: bridge + dragonbank-frontend-net: + driver: bridge \ No newline at end of file diff --git a/DragonBank/backend/models/__init__.py b/DragonBank/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/DragonBank/backend/models/beneficiaire.py b/DragonBank/backend/models/beneficiaire.py new file mode 100644 index 0000000..f5a028c --- /dev/null +++ b/DragonBank/backend/models/beneficiaire.py @@ -0,0 +1,198 @@ +""" +DragonBank - Modele Beneficiaire +================================== +Classe DAO gerant les operations en base de donnees +relatives aux beneficiaires de virements. + +Version : 3.0 +""" + +import logging + +from database import serialiser_ligne, serialiser_lignes + +journaliseur = logging.getLogger('dragonbank.beneficiaire') + + +class BeneficiaireDAO: + """ + Objet d'acces aux donnees pour la table beneficiaries. + + Applique les regles metier : + - Interdiction de s'ajouter soi-meme comme beneficiaire. + - Unicite du numero de compte par utilisateur. + """ + + # ========================================================= + # LECTURE + # ========================================================= + + def lister_par_utilisateur(self, connexion, id_utilisateur): + """ + Retourne tous les beneficiaires enregistres par un utilisateur. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + + Returns: + list[dict]: Liste des beneficiaires tries alphabetiquement. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id, beneficiary_name, bank_name, account_number, + iban, bic, status, created_at + FROM beneficiaries + WHERE user_id = %s + ORDER BY beneficiary_name ASC + """, + (id_utilisateur,) + ) + return serialiser_lignes(curseur.fetchall()) + + def trouver_approuve(self, connexion, id_beneficiaire, id_utilisateur): + """ + Recupere un beneficiaire approuve appartenant a l'utilisateur. + + Utilise lors des virements pour verifier que le beneficiaire + est bien enregistre et approuve. + + Args: + connexion : Connexion PostgreSQL active. + id_beneficiaire : UUID du beneficiaire. + id_utilisateur : UUID de l'utilisateur proprietaire. + + Returns: + dict : Donnees du beneficiaire. + None : Si introuvable, non approuve ou acces refuse. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id, beneficiary_name, account_number, bank_name + FROM beneficiaries + WHERE id = %s AND user_id = %s AND status = 'approved' + """, + (id_beneficiaire, id_utilisateur) + ) + return curseur.fetchone() + + # ========================================================= + # CREATION + # ========================================================= + + def ajouter(self, connexion, id_utilisateur, nom, numero_compte, + nom_banque='DragonBank', iban=None, bic=None): + """ + Enregistre un nouveau beneficiaire. + + Verifie que l'utilisateur ne s'ajoute pas lui-meme + et qu'il n'y a pas de doublon sur le numero de compte. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + nom (str) : Nom du beneficiaire. + numero_compte (str): Numero de compte du beneficiaire. + nom_banque (str) : Nom de la banque (defaut: DragonBank). + iban (str) : Code IBAN (optionnel). + bic (str) : Code BIC/SWIFT (optionnel). + + Returns: + dict: Les donnees du beneficiaire cree. + + Raises: + ValueError: Si auto-ajout ou doublon detecte. + """ + # Interdiction de s'ajouter soi-meme + if self._est_propre_compte(connexion, id_utilisateur, numero_compte): + raise ValueError( + "Vous ne pouvez pas vous ajouter vous-meme comme beneficiaire" + ) + + # Verification de l'absence de doublon + if self._existe_deja(connexion, id_utilisateur, numero_compte): + raise ValueError("Ce beneficiaire est deja enregistre") + + curseur = connexion.cursor() + curseur.execute( + """ + INSERT INTO beneficiaries + (user_id, beneficiary_name, bank_name, account_number, iban, bic) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, beneficiary_name, bank_name, account_number, + iban, bic, status, created_at + """, + (id_utilisateur, nom, nom_banque, numero_compte, + iban or None, bic or None) + ) + journaliseur.info("Beneficiaire '%s' ajoute pour l'utilisateur %s", nom, id_utilisateur) + return serialiser_ligne(curseur.fetchone()) + + # ========================================================= + # SUPPRESSION + # ========================================================= + + def supprimer(self, connexion, id_beneficiaire, id_utilisateur): + """ + Supprime un beneficiaire en verifiant la propriete. + + Args: + connexion : Connexion PostgreSQL active. + id_beneficiaire : UUID du beneficiaire. + id_utilisateur : UUID de l'utilisateur proprietaire. + + Returns: + dict : Donnees du beneficiaire supprime (nom inclus). + None : Si introuvable ou acces refuse. + """ + curseur = connexion.cursor() + curseur.execute( + 'DELETE FROM beneficiaries WHERE id = %s AND user_id = %s' + ' RETURNING id, beneficiary_name', + (id_beneficiaire, id_utilisateur) + ) + return curseur.fetchone() + + # ========================================================= + # UTILITAIRES PRIVES + # ========================================================= + + def _est_propre_compte(self, connexion, id_utilisateur, numero_compte): + """ + Verifie si le numero correspond a un compte de l'utilisateur. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + numero_compte : Numero a verifier. + + Returns: + bool: True si c'est l'un de ses propres comptes. + """ + curseur = connexion.cursor() + curseur.execute( + 'SELECT id FROM accounts WHERE user_id = %s AND account_number = %s', + (id_utilisateur, numero_compte) + ) + return curseur.fetchone() is not None + + def _existe_deja(self, connexion, id_utilisateur, numero_compte): + """ + Verifie si un beneficiaire avec ce numero est deja enregistre. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + numero_compte : Numero a verifier. + + Returns: + bool: True si le beneficiaire existe deja. + """ + curseur = connexion.cursor() + curseur.execute( + 'SELECT id FROM beneficiaries WHERE user_id = %s AND account_number = %s', + (id_utilisateur, numero_compte) + ) + return curseur.fetchone() is not None diff --git a/DragonBank/backend/models/compte.py b/DragonBank/backend/models/compte.py new file mode 100644 index 0000000..3e8bd2b --- /dev/null +++ b/DragonBank/backend/models/compte.py @@ -0,0 +1,264 @@ +""" +DragonBank - Modele Compte Bancaire +===================================== +Classe DAO gerant toutes les operations en base de donnees +relatives aux comptes bancaires (courant, livret A, assurance vie). + +Version : 3.0 +""" + +import uuid +import decimal +import logging + +from database import serialiser_ligne, serialiser_lignes, enregistrer_audit +from config import TAUX_INTERETS, TYPES_COMPTE_UNIQUES + +journaliseur = logging.getLogger('dragonbank.compte') + + +class CompteDAO: + """ + Objet d'acces aux donnees pour la table accounts. + + Encapsule toutes les requetes SQL liees aux comptes bancaires + et applique les regles metier (unicite du Livret A, validation + du solde avant virement...). + """ + + # ========================================================= + # LECTURE + # ========================================================= + + def lister_par_utilisateur(self, connexion, id_utilisateur): + """ + Retourne tous les comptes actifs d'un utilisateur. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + + Returns: + list[dict]: Liste des comptes tries par date de creation. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id, account_number, account_type, balance, currency, + status, interest_rate, created_at + FROM accounts + WHERE user_id = %s AND status = 'active' + ORDER BY created_at ASC + """, + (id_utilisateur,) + ) + return serialiser_lignes(curseur.fetchall()) + + def trouver_par_id(self, connexion, id_compte, id_utilisateur): + """ + Recupere un compte par son UUID en verifiant la propriete. + + Args: + connexion : Connexion PostgreSQL active. + id_compte : UUID du compte. + id_utilisateur : UUID du proprietaire attendu. + + Returns: + dict : Donnees du compte. + None : Si introuvable ou n'appartient pas a l'utilisateur. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id, account_number, account_type, balance, currency, + status, interest_rate, created_at, updated_at + FROM accounts + WHERE id = %s AND user_id = %s + """, + (id_compte, id_utilisateur) + ) + return serialiser_ligne(curseur.fetchone()) + + def trouver_actif(self, connexion, id_compte, id_utilisateur): + """ + Recupere un compte actif en verifiant la propriete. + + Utilise lors des virements pour s'assurer que le compte + est actif (ni ferme ni gele) et appartient a l'utilisateur. + + Args: + connexion : Connexion PostgreSQL active. + id_compte : UUID du compte. + id_utilisateur : UUID du proprietaire. + + Returns: + dict : Donnees du compte actif. + None : Si introuvable, inactif ou acces refuse. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id, balance, account_number, account_type + FROM accounts + WHERE id = %s AND user_id = %s AND status = 'active' + """, + (id_compte, id_utilisateur) + ) + return curseur.fetchone() + + def trouver_par_numero(self, connexion, numero_compte): + """ + Recherche un compte actif par son numero de compte. + + Utilise lors des virements vers un beneficiaire pour trouver + le compte destination a partir du numero enregistre. + + Args: + connexion : Connexion PostgreSQL active. + numero_compte (str): Numero de compte (format DRGxxxxx). + + Returns: + dict : Donnees du compte. + None : Si introuvable ou inactif. + """ + curseur = connexion.cursor() + curseur.execute( + "SELECT id FROM accounts WHERE account_number = %s AND status = 'active'", + (numero_compte,) + ) + return curseur.fetchone() + + # ========================================================= + # CREATION + # ========================================================= + + def ouvrir(self, connexion, id_utilisateur, type_compte, depot_initial=None): + """ + Ouvre un nouveau compte bancaire. + + Applique les regles metier : + - Unicite du Livret A et de l'Assurance Vie. + - Enregistrement du depot initial comme transaction si > 0. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + type_compte (str): 'courant', 'livret_a' ou 'assurance_vie'. + depot_initial : Montant en euros (Decimal ou None). + + Returns: + dict: Les donnees du compte cree. + + Raises: + ValueError: Si le type est invalide ou si un compte unique existe deja. + """ + if type_compte not in TAUX_INTERETS: + raise ValueError( + "Type de compte invalide. Valeurs acceptees : " + + ", ".join(TAUX_INTERETS.keys()) + ) + + if type_compte in TYPES_COMPTE_UNIQUES: + if self._existe_compte_unique(connexion, id_utilisateur, type_compte): + raise ValueError("Vous possedez deja un compte " + type_compte) + + if depot_initial is None: + depot_initial = decimal.Decimal('0.00') + + # Bonus d'ouverture de 100e uniquement pour le compte courant + if type_compte == 'courant': + depot_initial += decimal.Decimal('100.00') + + taux = float(TAUX_INTERETS[type_compte]) + numero = 'DRG' + str(uuid.uuid4().int)[:13].zfill(13) + + curseur = connexion.cursor() + curseur.execute( + """ + INSERT INTO accounts + (user_id, account_number, account_type, balance, interest_rate) + VALUES (%s, %s, %s, %s, %s) + RETURNING id, account_number, account_type, balance, interest_rate, created_at + """, + (id_utilisateur, numero, type_compte, float(depot_initial), taux) + ) + compte = curseur.fetchone() + + # Enregistrement du depot initial et bonus comme transaction tracable + if depot_initial > 0: + description_depot = "Depot initial a l'ouverture du compte" + if type_compte == 'courant': + description_depot += " (incluant 100e de bonus)" + + curseur.execute( + """ + INSERT INTO transactions + (to_account_id, transaction_type, amount, description, status, executed_at) + VALUES (%s, 'depot', %s, %s, 'completed', NOW()) + """, + (str(compte['id']), float(depot_initial), description_depot) + ) + + journaliseur.info("Compte %s ouvert pour l'utilisateur %s", type_compte, id_utilisateur) + return serialiser_ligne(compte) + + # ========================================================= + # MISE A JOUR DU SOLDE + # ========================================================= + + def debiter(self, connexion, id_compte, montant): + """ + Deduit un montant du solde d'un compte. + + Args: + connexion : Connexion PostgreSQL active. + id_compte : UUID du compte a debiter. + montant : Montant a deduire (Decimal ou float). + """ + connexion.cursor().execute( + 'UPDATE accounts SET balance = balance - %s WHERE id = %s', + (float(montant), id_compte) + ) + + def crediter(self, connexion, id_compte, montant): + """ + Ajoute un montant au solde d'un compte. + + Args: + connexion : Connexion PostgreSQL active. + id_compte : UUID du compte a crediter. + montant : Montant a ajouter (Decimal ou float). + """ + connexion.cursor().execute( + 'UPDATE accounts SET balance = balance + %s WHERE id = %s', + (float(montant), id_compte) + ) + + # ========================================================= + # UTILITAIRES PRIVES + # ========================================================= + + def _existe_compte_unique(self, connexion, id_utilisateur, type_compte): + """ + Verifie si l'utilisateur possede deja un compte du type donne. + + Methode privee (prefixe _) appelee avant l'ouverture + d'un Livret A ou d'une Assurance Vie. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + type_compte (str): Type de compte a verifier. + + Returns: + bool: True si un compte actif de ce type existe deja. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id FROM accounts + WHERE user_id = %s AND account_type = %s AND status = 'active' + """, + (id_utilisateur, type_compte) + ) + return curseur.fetchone() is not None diff --git a/DragonBank/backend/models/simulateur.py b/DragonBank/backend/models/simulateur.py new file mode 100644 index 0000000..6969195 --- /dev/null +++ b/DragonBank/backend/models/simulateur.py @@ -0,0 +1,113 @@ +""" +DragonBank - Simulateur d'Epargne +=================================== +Classe de logique pure pour la simulation de croissance d'epargne. + +Cette classe ne fait aucun acces a la base de donnees. +Elle contient uniquement les formules mathematiques de calcul +des interets composes avec versements mensuels reguliers. + +Version : 3.0 +""" + + +class Simulateur: + """ + Calcule la croissance d'un capital epargne par interets composes. + + Formule appliquee pour chaque mois : + solde = solde * (1 + taux_mensuel) + versement_mensuel + + Ou taux_mensuel = taux_annuel / 100 / 12. + + Cette approche modelise les interets composes mensuels, + qui correspondent au fonctionnement reel du Livret A et + de l'assurance vie en fonds euros. + """ + + def simuler(self, capital_initial, taux_annuel, duree_annees, + versement_mensuel=0.0): + """ + Simule la croissance d'un capital sur une duree donnee. + + Calcule le capital annee par annee en appliquant les + interets composes chaque mois et en ajoutant les versements. + + Args: + capital_initial (float) : Montant de depart en euros. + taux_annuel (float) : Taux d'interet annuel en %. + duree_annees (int) : Duree de la simulation en annees. + versement_mensuel (float): Versement mensuel regulier (defaut: 0). + + Returns: + dict: Resultats complets de la simulation : + - capital_initial (float) : Mise de depart. + - taux_annuel (float) : Taux utilise. + - duree_annees (int) : Duree de la simulation. + - versement_mensuel (float) : Versement mensuel. + - capital_final (float) : Capital total a la fin. + - total_verse (float) : Somme totale versee (capital + versements). + - total_interets (float) : Interets generes. + - gain_pourcentage (float) : Gain en % par rapport au total verse. + - courbe (list[dict]) : Evolution annee par annee. + """ + taux_mensuel = taux_annuel / 100.0 / 12.0 + solde_courant = float(capital_initial) + total_verse = float(capital_initial) + courbe = [] + + for annee in range(1, duree_annees + 1): + solde_courant, total_verse = self._calculer_annee( + solde_courant, total_verse, taux_mensuel, versement_mensuel + ) + + courbe.append({ + 'annee': annee, + 'solde': round(solde_courant, 2), + 'total_verse': round(total_verse, 2), + 'interets': round(solde_courant - total_verse, 2) + }) + + capital_final = round(solde_courant, 2) + total_interets = round(solde_courant - total_verse, 2) + gain_pct = round((total_interets / max(total_verse, 1)) * 100, 2) + + return { + 'capital_initial': capital_initial, + 'taux_annuel': taux_annuel, + 'duree_annees': duree_annees, + 'versement_mensuel': versement_mensuel, + 'capital_final': capital_final, + 'total_verse': round(total_verse, 2), + 'total_interets': total_interets, + 'gain_pourcentage': gain_pct, + 'courbe': courbe + } + + # ========================================================= + # UTILITAIRE PRIVE + # ========================================================= + + def _calculer_annee(self, solde, total_verse, taux_mensuel, versement_mensuel): + """ + Calcule le solde et le total verse apres 12 mois. + + Applique la formule des interets composes mois par mois, + puis ajoute le versement mensuel. + + Args: + solde (float) : Solde en debut d'annee. + total_verse (float) : Total deja verse en debut d'annee. + taux_mensuel (float) : Taux mensuel (taux_annuel / 12 / 100). + versement_mensuel (float): Versement ajoute chaque mois. + + Returns: + tuple: (nouveau_solde, nouveau_total_verse) apres 12 mois. + """ + for mois in range(12): + if taux_mensuel > 0: + solde = solde * (1.0 + taux_mensuel) + solde = solde + versement_mensuel + total_verse = total_verse + versement_mensuel + + return solde, total_verse diff --git a/DragonBank/backend/models/transaction.py b/DragonBank/backend/models/transaction.py new file mode 100644 index 0000000..ea3a712 --- /dev/null +++ b/DragonBank/backend/models/transaction.py @@ -0,0 +1,447 @@ +""" +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()) diff --git a/DragonBank/backend/models/utilisateur.py b/DragonBank/backend/models/utilisateur.py new file mode 100644 index 0000000..2325748 --- /dev/null +++ b/DragonBank/backend/models/utilisateur.py @@ -0,0 +1,243 @@ +""" +DragonBank - Modele Utilisateur +================================ +Classe DAO (Data Access Object) gerant toutes les operations +en base de donnees relatives aux utilisateurs. + +Pattern DAO : separe la logique d'acces aux donnees +de la logique metier et des routes Flask. + +Version : 3.0 +""" + +import uuid +import logging + +from database import serialiser_ligne, enregistrer_audit +from auth import hacher_mot_de_passe, verifier_mot_de_passe, simuler_verification_bcrypt + +journaliseur = logging.getLogger('dragonbank.utilisateur') + + +class UtilisateurDAO: + """ + Objet d'acces aux donnees pour la table users. + + Chaque methode recoit une connexion active en parametre. + La gestion des transactions (commit/rollback) reste + a la charge de la route appelante. + """ + + # ========================================================= + # CREATION + # ========================================================= + + def creer(self, connexion, email, mot_de_passe, prenom, nom, + telephone=None, adresse=None, date_naissance=None): + """ + Cree un nouvel utilisateur en base de donnees. + + Args: + connexion : Connexion PostgreSQL active. + email (str) : Adresse email (deja validee et normalisee). + mot_de_passe (str): Mot de passe en clair (sera hache ici). + prenom (str) : Prenom de l'utilisateur. + nom (str) : Nom de famille. + telephone (str): Numero de telephone (optionnel). + adresse (str) : Adresse postale (optionnel). + date_naissance : Date de naissance au format YYYY-MM-DD (optionnel). + + Returns: + dict: Les donnees de l'utilisateur cree (sans le hash du mot de passe). + + Raises: + Exception: Si l'insertion echoue (email duplique, etc.). + """ + id_utilisateur = str(uuid.uuid4()) + hash_mdp = hacher_mot_de_passe(mot_de_passe) + + curseur = connexion.cursor() + curseur.execute( + """ + INSERT INTO users + (id, email, password_hash, first_name, last_name, + phone, address, date_of_birth) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, email, first_name, last_name, created_at + """, + ( + id_utilisateur, email, hash_mdp, + prenom, nom, + telephone or None, + adresse or None, + date_naissance or None + ) + ) + utilisateur = curseur.fetchone() + + journaliseur.info("Nouvel utilisateur cree : %s", email) + return serialiser_ligne(utilisateur) + + # ========================================================= + # LECTURE + # ========================================================= + + def trouver_par_email(self, connexion, email): + """ + Recherche un utilisateur par son adresse email. + + Args: + connexion : Connexion PostgreSQL active. + email (str): Email a rechercher. + + Returns: + dict : Donnees de l'utilisateur (avec hash) si trouve. + None : Si aucun utilisateur ne correspond. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id, email, password_hash, first_name, last_name, is_active + FROM users + WHERE email = %s + """, + (email,) + ) + return curseur.fetchone() + + def trouver_par_id(self, connexion, id_utilisateur): + """ + Recupere le profil complet d'un utilisateur actif par son UUID. + + N'inclut pas le hash du mot de passe dans le resultat. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + + Returns: + dict : Profil de l'utilisateur. + None : Si introuvable ou desactive. + """ + curseur = connexion.cursor() + curseur.execute( + """ + SELECT id, email, first_name, last_name, phone, address, + date_of_birth, created_at, last_login + FROM users + WHERE id = %s AND is_active = TRUE + """, + (id_utilisateur,) + ) + return serialiser_ligne(curseur.fetchone()) + + def email_existe(self, connexion, email): + """ + Verifie si une adresse email est deja utilisee. + + Args: + connexion : Connexion PostgreSQL active. + email (str): Email a verifier. + + Returns: + bool: True si l'email existe deja, False sinon. + """ + curseur = connexion.cursor() + curseur.execute('SELECT id FROM users WHERE email = %s', (email,)) + return curseur.fetchone() is not None + + # ========================================================= + # MISE A JOUR + # ========================================================= + + def mettre_a_jour_derniere_connexion(self, connexion, id_utilisateur): + """ + Met a jour la date de derniere connexion de l'utilisateur. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + """ + curseur = connexion.cursor() + curseur.execute( + 'UPDATE users SET last_login = NOW() WHERE id = %s', + (str(id_utilisateur),) + ) + + def mettre_a_jour_profil(self, connexion, id_utilisateur, champs): + """ + Met a jour les champs modifiables du profil utilisateur. + + Seuls les champs fournis en parametre sont modifies. + Les champs sensibles (email, mot de passe) sont ignores. + + Args: + connexion : Connexion PostgreSQL active. + id_utilisateur : UUID de l'utilisateur. + champs (dict) : Dictionnaire {nom_champ: nouvelle_valeur}. + + Returns: + dict: Profil mis a jour. + """ + clause_set = ', '.join(champ + ' = %s' for champ in champs) + valeurs = list(champs.values()) + valeurs.append(id_utilisateur) + + curseur = connexion.cursor() + curseur.execute( + 'UPDATE users SET ' + clause_set + + ' WHERE id = %s' + ' RETURNING id, email, first_name, last_name, phone, address', + valeurs + ) + return serialiser_ligne(curseur.fetchone()) + + def mettre_a_jour_mot_de_passe(self, connexion, id_utilisateur, nouveau_hash): + """Met a jour le l'empreinte bcrypt de l'utilisateur.""" + curseur = connexion.cursor() + curseur.execute('UPDATE users SET password_hash = %s WHERE id = %s', (nouveau_hash, id_utilisateur)) + + def mettre_a_jour_email(self, connexion, id_utilisateur, nouvel_email): + """Met a jour l'adresse e-mail.""" + curseur = connexion.cursor() + curseur.execute('UPDATE users SET email = %s WHERE id = %s', (nouvel_email, id_utilisateur)) + + # ========================================================= + # AUTHENTIFICATION + # ========================================================= + + def authentifier(self, connexion, email, mot_de_passe): + """ + Verifie les identifiants d'un utilisateur. + + Gere la protection contre les attaques temporelles : + si l'email est inconnu, une verification bcrypt fictive est + effectuee pour uniformiser le temps de reponse. + + Args: + connexion : Connexion PostgreSQL active. + email (str) : Email soumis. + mot_de_passe (str) : Mot de passe en clair soumis. + + Returns: + dict : Donnees de l'utilisateur si authentification reussie. + None : Si les identifiants sont incorrects. + + Raises: + PermissionError: Si le compte est desactive. + """ + utilisateur = self.trouver_par_email(connexion, email) + + if not utilisateur: + # On simule le temps bcrypt pour eviter la timing attack + simuler_verification_bcrypt() + return None + + if not utilisateur['is_active']: + raise PermissionError("Ce compte a ete desactive. Contactez le support.") + + if not verifier_mot_de_passe(mot_de_passe, utilisateur['password_hash']): + journaliseur.warning("Echec de connexion pour : %s", email) + return None + + return utilisateur diff --git a/DragonBank/backend/requirements.txt b/DragonBank/backend/requirements.txt index b41751e..ed0e09c 100644 --- a/DragonBank/backend/requirements.txt +++ b/DragonBank/backend/requirements.txt @@ -3,4 +3,4 @@ Flask-CORS==4.0.0 psycopg2-binary==2.9.9 bcrypt==4.1.2 PyJWT==2.8.0 -gunicorn==21.2.0 \ No newline at end of file +gunicorn==21.2.0 diff --git a/DragonBank/backend/services/auth_service.py b/DragonBank/backend/services/auth_service.py new file mode 100644 index 0000000..15fd6a7 --- /dev/null +++ b/DragonBank/backend/services/auth_service.py @@ -0,0 +1,83 @@ +from database import creer_connexion, enregistrer_audit +from auth import generer_token, obtenir_ip_client +from validators import valider_champs_obligatoires, valider_email, valider_mot_de_passe +from models.utilisateur import UtilisateurDAO +from models.compte import CompteDAO + +class AuthService: + def __init__(self): + self.utilisateur_dao = UtilisateurDAO() + self.compte_dao = CompteDAO() + + def inscrire(self, donnees): + conn = creer_connexion() + try: + valider_champs_obligatoires(donnees, ['email', 'password', 'first_name', 'last_name']) + email = valider_email(donnees['email']) + valider_mot_de_passe(donnees['password']) + + if self.utilisateur_dao.email_existe(conn, email): + raise ValueError('Cette adresse email est deja associee a un compte') + + utilisateur = self.utilisateur_dao.creer( + conn, email, donnees['password'], + donnees['first_name'].strip(), + donnees['last_name'].strip(), + donnees.get('phone', '').strip() or None, + donnees.get('address', '').strip() or None, + donnees.get('date_of_birth') or None + ) + + compte = self.compte_dao.ouvrir(conn, utilisateur['id'], 'courant') + + enregistrer_audit(conn.cursor(), utilisateur['id'], 'REGISTER', { + 'email': email, 'account_number': compte['account_number'] + }, obtenir_ip_client()) + + conn.commit() + token = generer_token(utilisateur['id'], email) + + return { + 'message': 'Inscription reussie ! Bienvenue chez DragonBank.', + 'user': utilisateur, + 'token': token, + 'account_number': compte['account_number'] + } + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def connecter(self, donnees): + if not donnees or not donnees.get('email') or not donnees.get('password'): + raise ValueError('Email et mot de passe requis') + + conn = creer_connexion() + try: + email = donnees['email'].strip().lower() + utilisateur = self.utilisateur_dao.authentifier(conn, email, donnees['password']) + + if not utilisateur: + raise PermissionError('Email ou mot de passe incorrect') + + self.utilisateur_dao.mettre_a_jour_derniere_connexion(conn, str(utilisateur['id'])) + enregistrer_audit(conn.cursor(), str(utilisateur['id']), 'LOGIN', + {'ip': obtenir_ip_client()}, obtenir_ip_client()) + conn.commit() + + return { + 'message': 'Connexion reussie', + 'token': generer_token(utilisateur['id'], utilisateur['email']), + 'user': { + 'id': str(utilisateur['id']), + 'email': utilisateur['email'], + 'first_name': utilisateur['first_name'], + 'last_name': utilisateur['last_name'] + } + } + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/DragonBank/backend/services/beneficiaire_service.py b/DragonBank/backend/services/beneficiaire_service.py new file mode 100644 index 0000000..26df159 --- /dev/null +++ b/DragonBank/backend/services/beneficiaire_service.py @@ -0,0 +1,54 @@ +from database import creer_connexion, enregistrer_audit +from auth import obtenir_ip_client +from models.beneficiaire import BeneficiaireDAO +from validators import valider_champs_obligatoires + +class BeneficiaireService: + def __init__(self): + self.beneficiaire_dao = BeneficiaireDAO() + + def obtenir_beneficiaires(self, id_utilisateur_courant): + conn = creer_connexion() + try: + return self.beneficiaire_dao.lister_par_utilisateur(conn, id_utilisateur_courant) + finally: + conn.close() + + def ajouter_beneficiaire(self, id_utilisateur_courant, donnees): + conn = creer_connexion() + try: + valider_champs_obligatoires(donnees, ['beneficiary_name', 'account_number']) + beneficiaire = self.beneficiaire_dao.ajouter( + conn, + id_utilisateur_courant, + donnees['beneficiary_name'].strip(), + donnees['account_number'].strip().upper(), + donnees.get('bank_name', 'DragonBank').strip(), + donnees.get('iban', '').strip().upper() or None, + donnees.get('bic', '').strip().upper() or None + ) + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'ADD_BENEFICIARY', + {'nom': donnees['beneficiary_name']}, obtenir_ip_client()) + conn.commit() + return beneficiaire + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def supprimer_beneficiaire(self, id_utilisateur_courant, id_beneficiaire): + conn = creer_connexion() + try: + supprime = self.beneficiaire_dao.supprimer(conn, id_beneficiaire, id_utilisateur_courant) + if not supprime: + raise ValueError('Beneficiaire introuvable') + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'DELETE_BENEFICIARY', + {'id': id_beneficiaire}, obtenir_ip_client()) + conn.commit() + return True + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/DragonBank/backend/services/compte_service.py b/DragonBank/backend/services/compte_service.py new file mode 100644 index 0000000..e4d2acd --- /dev/null +++ b/DragonBank/backend/services/compte_service.py @@ -0,0 +1,68 @@ +from database import creer_connexion, enregistrer_audit +from auth import obtenir_ip_client +from models.compte import CompteDAO +from models.transaction import TransactionDAO + +class CompteService: + def __init__(self): + self.compte_dao = CompteDAO() + self.transaction_dao = TransactionDAO() + + def obtenir_comptes(self, id_utilisateur_courant): + conn = creer_connexion() + try: + return self.compte_dao.lister_par_utilisateur(conn, id_utilisateur_courant) + finally: + conn.close() + + def ouvrir_compte(self, id_utilisateur_courant, donnees): + donnees = donnees or {} + conn = creer_connexion() + try: + import decimal as _d + depot_brut = donnees.get('initial_deposit') + depot = _d.Decimal(str(depot_brut)) if depot_brut else _d.Decimal('0') + if depot < 0: + raise ValueError('Le depot initial ne peut pas etre negatif') + + compte = self.compte_dao.ouvrir( + conn, id_utilisateur_courant, + donnees.get('account_type', 'courant'), + depot + ) + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'OPEN_ACCOUNT', + {'type': donnees.get('account_type'), 'depot': float(depot)}, + obtenir_ip_client()) + conn.commit() + return compte + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def obtenir_detail_compte(self, id_utilisateur_courant, id_compte): + conn = creer_connexion() + try: + compte = self.compte_dao.trouver_par_id(conn, id_compte, id_utilisateur_courant) + if not compte: + raise ValueError('Compte introuvable') + return compte + finally: + conn.close() + + def historique_solde(self, id_utilisateur_courant, id_compte): + conn = creer_connexion() + try: + compte = self.compte_dao.trouver_par_id(conn, id_compte, id_utilisateur_courant) + if not compte: + raise ValueError('Compte introuvable') + + points = self.transaction_dao.historique_solde(conn, id_compte, compte['balance']) + return { + 'account_type': compte['account_type'], + 'solde_actuel': compte['balance'], + 'points': points + } + finally: + conn.close() diff --git a/DragonBank/backend/services/stats_service.py b/DragonBank/backend/services/stats_service.py new file mode 100644 index 0000000..e0d13ed --- /dev/null +++ b/DragonBank/backend/services/stats_service.py @@ -0,0 +1,41 @@ +from database import creer_connexion, serialiser_valeur +from models.transaction import TransactionDAO + +class StatsService: + def __init__(self): + self.transaction_dao = TransactionDAO() + + def obtenir_statistiques(self, id_utilisateur_courant): + conn = creer_connexion() + try: + curseur = conn.cursor() + + curseur.execute( + """ + SELECT COALESCE(SUM(balance), 0) AS total_balance, + COUNT(*) AS total_accounts + FROM accounts + WHERE user_id = %s AND status = 'active' + """, + (id_utilisateur_courant,) + ) + stats_comptes = curseur.fetchone() + + stats_tx = self.transaction_dao.statistiques(conn, id_utilisateur_courant) + + curseur.execute( + 'SELECT COUNT(*) AS total_beneficiaries FROM beneficiaries WHERE user_id = %s', + (id_utilisateur_courant,) + ) + stats_ben = curseur.fetchone() + + return { + 'total_balance': serialiser_valeur(stats_comptes['total_balance']), + 'total_accounts': stats_comptes['total_accounts'], + 'monthly_transactions': stats_tx['transactions_mois'], + 'total_sent': serialiser_valeur(stats_tx['total_envoye']), + 'total_received': serialiser_valeur(stats_tx['total_recu']), + 'total_beneficiaries': stats_ben['total_beneficiaries'] + } + finally: + conn.close() diff --git a/DragonBank/backend/services/transaction_service.py b/DragonBank/backend/services/transaction_service.py new file mode 100644 index 0000000..b6533da --- /dev/null +++ b/DragonBank/backend/services/transaction_service.py @@ -0,0 +1,147 @@ +from database import creer_connexion, enregistrer_audit +from auth import obtenir_ip_client +from validators import valider_champs_obligatoires, valider_montant +from models.transaction import TransactionDAO +from models.compte import CompteDAO +from models.beneficiaire import BeneficiaireDAO + +class TransactionService: + def __init__(self): + self.transaction_dao = TransactionDAO() + self.compte_dao = CompteDAO() + self.beneficiaire_dao = BeneficiaireDAO() + + def virement_interne(self, id_utilisateur_courant, donnees): + conn = creer_connexion() + try: + valider_champs_obligatoires(donnees, ['from_account_id', 'to_account_id', 'amount']) + montant = valider_montant(donnees['amount']) + + if donnees['from_account_id'] == donnees['to_account_id']: + raise ValueError('Les comptes source et destination doivent etre differents') + + source = self.compte_dao.trouver_actif(conn, donnees['from_account_id'], id_utilisateur_courant) + dest = self.compte_dao.trouver_actif(conn, donnees['to_account_id'], id_utilisateur_courant) + + if not source: + raise ValueError('Compte source introuvable') + if not dest: + raise ValueError('Compte destination introuvable') + + libelle = donnees.get('description') or ( + 'Virement ' + source['account_type'] + ' vers ' + dest['account_type'] + ) + + transaction = self.transaction_dao.virement_interne( + conn, donnees['from_account_id'], donnees['to_account_id'], + montant, libelle, self.compte_dao + ) + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'TRANSFER_INTERNAL', + {'montant': float(montant), 'source': source['account_number'], + 'dest': dest['account_number']}, obtenir_ip_client()) + conn.commit() + return transaction + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def virement_beneficiaire(self, id_utilisateur_courant, donnees): + conn = creer_connexion() + try: + valider_champs_obligatoires(donnees, ['from_account_id', 'beneficiary_id', 'amount']) + montant = valider_montant(donnees['amount']) + + source = self.compte_dao.trouver_actif(conn, donnees['from_account_id'], id_utilisateur_courant) + if not source: + raise ValueError('Compte source introuvable') + + beneficiaire = self.beneficiaire_dao.trouver_approuve( + conn, donnees['beneficiary_id'], id_utilisateur_courant + ) + if not beneficiaire: + raise ValueError('Beneficiaire introuvable ou non approuve') + + dest = self.compte_dao.trouver_par_numero(conn, beneficiaire['account_number']) + if not dest: + raise ValueError('Le compte du beneficiaire est introuvable dans DragonBank') + + libelle = donnees.get('description') or 'Virement a ' + beneficiaire['beneficiary_name'] + + transaction = self.transaction_dao.virement_entre_personnes( + conn, donnees['from_account_id'], str(dest['id']), + montant, libelle, self.compte_dao + ) + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'TRANSFER_PERSON', + {'montant': float(montant), 'beneficiaire': beneficiaire['beneficiary_name']}, + obtenir_ip_client()) + conn.commit() + return transaction + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def virement_externe(self, id_utilisateur_courant, donnees): + conn = creer_connexion() + try: + valider_champs_obligatoires( + donnees, + ['account_id', 'amount', 'external_bank_name', 'external_account_number', 'direction'] + ) + direction = donnees['direction'] + if direction not in ('incoming', 'outgoing'): + raise ValueError('La direction doit etre "incoming" ou "outgoing"') + + montant = valider_montant(donnees['amount']) + compte = self.compte_dao.trouver_actif(conn, donnees['account_id'], id_utilisateur_courant) + if not compte: + raise ValueError('Compte introuvable') + + nom_banque = donnees['external_bank_name'].strip() + num_ext = donnees['external_account_number'].strip().upper() + libelle = donnees.get('description', '').strip() or ( + 'Virement ' + ('sortant vers ' if direction == 'outgoing' else 'entrant depuis ') + + nom_banque + ' (' + num_ext + ')' + ) + + transaction = self.transaction_dao.virement_externe( + conn, donnees['account_id'], montant, direction, + nom_banque, num_ext, libelle, self.compte_dao + ) + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'TRANSFER_EXTERNAL', + {'montant': float(montant), 'direction': direction, 'banque': nom_banque}, + obtenir_ip_client()) + conn.commit() + return transaction, direction + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def obtenir_transactions(self, id_utilisateur_courant, id_compte_filtre, type_tx_filtre, limite): + conn = creer_connexion() + try: + if id_compte_filtre: + if not self.compte_dao.trouver_par_id(conn, id_compte_filtre, id_utilisateur_courant): + raise ValueError('Compte introuvable ou acces refuse') + + transactions = self.transaction_dao.lister( + conn, id_utilisateur_courant, id_compte_filtre, type_tx_filtre, limite + ) + return transactions + finally: + conn.close() + + def exporter_csv(self, id_utilisateur_courant, id_compte_filtre): + conn = creer_connexion() + try: + if id_compte_filtre: + if not self.compte_dao.trouver_par_id(conn, id_compte_filtre, id_utilisateur_courant): + raise ValueError('Compte introuvable ou acces refuse') + return self.transaction_dao.exporter_csv(conn, id_utilisateur_courant, id_compte_filtre) + finally: + conn.close() diff --git a/DragonBank/backend/services/utilisateur_service.py b/DragonBank/backend/services/utilisateur_service.py new file mode 100644 index 0000000..30f6d78 --- /dev/null +++ b/DragonBank/backend/services/utilisateur_service.py @@ -0,0 +1,133 @@ +""" +DragonBank - Couche Service (Utilisateur) +========================================= +Ce module implĂ©mente le pattern 'Service Layer' (Couche MĂ©tier). + +Choix architecturaux : +1. SĂ©paration des responsabilitĂ©s (Separation of Concerns) : + Les routes Flask (Controllers) ne font que recevoir les requĂȘtes HTTP. + C'est cette classe Service qui concentre TOUTES les rĂšgles mĂ©tier complexes + (validation des mots de passe bcrypt, rĂšgles de profil, audits de sĂ©curitĂ©). +2. Gestion Transactionnelle (ACID) : + Chaque mĂ©thode du service encapsule l'ouverture et la fermeture de la connexion DB. + Si une exception survient pendant une mise Ă  jour, le bloc `except` appelle un `conn.rollback()` + pour garantir que la base de donnĂ©es ne reste jamais dans un Ă©tat instable. +3. Abstraction des DonnĂ©es (DAO) : + Le service instancie la classe `UtilisateurDAO` pour lire et Ă©crire les donnĂ©es. + Il ignore totalement la complexitĂ© des requĂȘtes SQL (Pattern Repository/DAO). +""" + +from database import creer_connexion, enregistrer_audit +from auth import obtenir_ip_client, verifier_mot_de_passe, hacher_mot_de_passe +from config import CHAMPS_PROFIL_MODIFIABLES +from validators import valider_email, valider_mot_de_passe +from models.utilisateur import UtilisateurDAO + +class UtilisateurService: + def __init__(self): + self.utilisateur_dao = UtilisateurDAO() + + def obtenir_profil(self, id_utilisateur_courant): + conn = creer_connexion() + try: + utilisateur = self.utilisateur_dao.trouver_par_id(conn, id_utilisateur_courant) + if not utilisateur: + raise ValueError('Utilisateur introuvable') + return utilisateur + finally: + conn.close() + + def modifier_profil(self, id_utilisateur_courant, donnees): + donnees = donnees or {} + champs = { + k: v.strip() + for k, v in donnees.items() + if k in CHAMPS_PROFIL_MODIFIABLES and isinstance(v, str) + } + if not champs: + raise ValueError('Aucun champ valide a mettre a jour') + + conn = creer_connexion() + try: + mis_a_jour = self.utilisateur_dao.mettre_a_jour_profil(conn, id_utilisateur_courant, champs) + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'UPDATE_PROFILE', + {'champs': list(champs.keys())}, obtenir_ip_client()) + conn.commit() + return mis_a_jour + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def changer_mot_de_passe(self, id_utilisateur_courant, donnees): + donnees = donnees or {} + ancien_mot_de_passe = donnees.get('current_password') + nouveau_mot_de_passe = donnees.get('new_password') + + if not ancien_mot_de_passe or not nouveau_mot_de_passe: + raise ValueError('Le mot de passe actuel et le nouveau mot de passe sont requis') + + valider_mot_de_passe(nouveau_mot_de_passe) + + conn = creer_connexion() + try: + utilisateur = self.utilisateur_dao.trouver_par_id(conn, id_utilisateur_courant) + if not utilisateur: + raise ValueError('Utilisateur introuvable') + + utilisateur_complet = self.utilisateur_dao.trouver_par_email(conn, utilisateur['email']) + + if not verifier_mot_de_passe(ancien_mot_de_passe, utilisateur_complet['password_hash']): + raise PermissionError('Le mot de passe actuel est incorrect') + + nouveau_hash = hacher_mot_de_passe(nouveau_mot_de_passe) + self.utilisateur_dao.mettre_a_jour_mot_de_passe(conn, id_utilisateur_courant, nouveau_hash) + + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'UPDATE_PASSWORD', {}, obtenir_ip_client()) + conn.commit() + return True + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def changer_email(self, id_utilisateur_courant, donnees): + donnees = donnees or {} + mot_de_passe = donnees.get('password') + nouvel_email = donnees.get('new_email') + + if not mot_de_passe or not nouvel_email: + raise ValueError('Le mot de passe et le nouvel email sont requis') + + email_valide = valider_email(nouvel_email) + + conn = creer_connexion() + try: + utilisateur = self.utilisateur_dao.trouver_par_id(conn, id_utilisateur_courant) + if not utilisateur: + raise ValueError('Utilisateur introuvable') + + if utilisateur['email'] == email_valide: + raise ValueError("Le nouvel email est identique a l'actuel") + + if self.utilisateur_dao.email_existe(conn, email_valide): + raise ValueError('Cette adresse email est deja utilisee') + + utilisateur_complet = self.utilisateur_dao.trouver_par_email(conn, utilisateur['email']) + if not verifier_mot_de_passe(mot_de_passe, utilisateur_complet['password_hash']): + raise PermissionError('Mot de passe incorrect') + + self.utilisateur_dao.mettre_a_jour_email(conn, id_utilisateur_courant, email_valide) + + enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'UPDATE_EMAIL', + {'ancien': utilisateur['email'], 'nouveau': email_valide}, + obtenir_ip_client()) + conn.commit() + return True + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/DragonBank/backend/validators.py b/DragonBank/backend/validators.py new file mode 100644 index 0000000..fe18496 --- /dev/null +++ b/DragonBank/backend/validators.py @@ -0,0 +1,181 @@ +""" +DragonBank - Validateurs +========================= +Centralise toutes les fonctions de validation des donnees +recues depuis les requetes HTTP. + +Principe : chaque validateur retourne soit la valeur nettoyee, +soit leve une ValueError avec un message explicite. +Les routes n'ont ainsi pas a contenir de logique de validation. + +Version : 3.0 +""" + +import re +import decimal + +from config import ( + MONTANT_MINIMUM_VIREMENT, + MONTANT_MAXIMUM_VIREMENT, + SIMULATION_DUREE_MIN, + SIMULATION_DUREE_MAX +) + + +# ============================================================ +# EXPRESSIONS REGULIERES +# ============================================================ + +# Valide le format d'une adresse email (RFC 5322 simplifie). +REGEX_EMAIL = re.compile( + r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$' +) + + +# ============================================================ +# VALIDATEURS +# ============================================================ + +def valider_email(email): + """ + Valide le format d'une adresse email. + + Args: + email (str): L'adresse email a valider. + + Returns: + str: L'email normalise (strip + lowercase). + + Raises: + ValueError: Si le format est invalide ou si l'email est vide. + """ + if not email or not email.strip(): + raise ValueError("L'adresse email est obligatoire") + + email_normalise = email.strip().lower() + + if not REGEX_EMAIL.match(email_normalise): + raise ValueError("Le format de l'adresse email est invalide") + + return email_normalise + + +def valider_mot_de_passe(mot_de_passe): + """ + Valide la complexite d'un mot de passe. + + Regles appliquees : + - Au moins 8 caracteres. + - Au moins une lettre majuscule (A-Z). + - Au moins une lettre minuscule (a-z). + - Au moins un chiffre (0-9). + + Args: + mot_de_passe (str): Le mot de passe en clair a verifier. + + Returns: + str: Le mot de passe inchange si valide. + + Raises: + ValueError: Si une regle n'est pas respectee. + """ + if not mot_de_passe or len(mot_de_passe) < 8: + raise ValueError("Le mot de passe doit contenir au moins 8 caracteres") + if not re.search(r'[A-Z]', mot_de_passe): + raise ValueError("Le mot de passe doit contenir au moins une majuscule") + if not re.search(r'[a-z]', mot_de_passe): + raise ValueError("Le mot de passe doit contenir au moins une minuscule") + if not re.search(r'\d', mot_de_passe): + raise ValueError("Le mot de passe doit contenir au moins un chiffre") + + return mot_de_passe + + +def valider_montant(montant_brut): + """ + Valide et convertit un montant en Decimal de maniere securisee. + + Utilise Decimal (et non float) pour eviter les erreurs d'arrondi + inherentes aux nombres a virgule flottante (ex: 0.1 + 0.2 != 0.3). + + Args: + montant_brut: Le montant brut a valider (str, int ou float). + + Returns: + Decimal: Le montant valide et converti. + + Raises: + ValueError: Si le montant est non numerique ou hors limites. + """ + try: + montant = decimal.Decimal(str(montant_brut)) + except (decimal.InvalidOperation, TypeError): + raise ValueError("Le montant doit etre un nombre valide") + + if montant < MONTANT_MINIMUM_VIREMENT: + raise ValueError( + "Le montant minimum est de " + str(MONTANT_MINIMUM_VIREMENT) + " euros" + ) + if montant > MONTANT_MAXIMUM_VIREMENT: + raise ValueError( + "Le montant maximum par virement est de " + str(MONTANT_MAXIMUM_VIREMENT) + " euros" + ) + + return montant + + +def valider_champs_obligatoires(donnees, champs): + """ + Verifie que tous les champs obligatoires sont presents et non vides. + + Args: + donnees (dict): Dictionnaire des donnees recues. + champs (list) : Liste des noms de champs obligatoires. + + Returns: + None + + Raises: + ValueError: Liste des champs manquants si au moins un est absent. + """ + if not donnees: + raise ValueError("Le corps de la requete JSON est manquant") + + manquants = [] + for champ in champs: + valeur = donnees.get(champ, '') + if not valeur or (isinstance(valeur, str) and not valeur.strip()): + manquants.append(champ) + + if manquants: + raise ValueError("Champs obligatoires manquants : " + ", ".join(manquants)) + + +def valider_parametres_simulateur(capital, taux, duree, versement): + """ + Valide les parametres d'entree du simulateur d'epargne. + + Args: + capital (float): Capital initial en euros. + taux (float): Taux annuel en pourcentage. + duree (int) : Duree en annees. + versement (float): Versement mensuel en euros. + + Returns: + None + + Raises: + ValueError: Si un parametre est invalide. + """ + if capital < 0: + raise ValueError("Le capital initial ne peut pas etre negatif") + if taux < 0 or taux > 100: + raise ValueError("Le taux annuel doit etre compris entre 0 et 100 %") + if duree < SIMULATION_DUREE_MIN or duree > SIMULATION_DUREE_MAX: + raise ValueError( + "La duree doit etre comprise entre " + + str(SIMULATION_DUREE_MIN) + " et " + + str(SIMULATION_DUREE_MAX) + " ans" + ) + if versement < 0: + raise ValueError("Le versement mensuel ne peut pas etre negatif") diff --git a/DragonBank/docker-compose.yml b/DragonBank/docker-compose.yml index cfbf9f4..b02d8e3 100644 --- a/DragonBank/docker-compose.yml +++ b/DragonBank/docker-compose.yml @@ -7,7 +7,9 @@ services: environment: POSTGRES_DB: dragonbank POSTGRES_USER: dragonadmin - POSTGRES_PASSWORD: dragonpass + POSTGRES_PASSWORD_FILE: /run/secrets/db_password + secrets: + - db_password volumes: - postgres_data:/var/lib/postgresql/data - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql @@ -29,9 +31,11 @@ services: dockerfile: Dockerfile container_name: dragonbank-backend environment: - DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank + POSTGRES_PASSWORD_FILE: /run/secrets/db_password SECRET_KEY: dragonbank-super-secret-key-2024 FLASK_ENV: production + secrets: + - db_password depends_on: db: condition: service_healthy @@ -77,17 +81,19 @@ services: dockerfile: Dockerfile container_name: dragonbank-interests environment: - DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank + POSTGRES_PASSWORD_FILE: /run/secrets/db_password INTEREST_RATE_LIVRET_A: 0.03 INTEREST_RATE_ASSURANCE_VIE: 0.02 - INTERVAL_SECONDS: 86400 + INTERVAL_SECONDS: 60 + secrets: + - db_password depends_on: db: condition: service_healthy networks: - dragonbank-backend-net healthcheck: - test: ["CMD", "python", "-c", "import psycopg2; psycopg2.connect('postgresql://dragonadmin:dragonpass@db:5432/dragonbank')"] + test: ["CMD", "python", "-c", "import psycopg2, os; p = open('/run/secrets/db_password').read().strip() if os.path.exists('/run/secrets/db_password') else 'dragonpass'; psycopg2.connect(f'postgresql://dragonadmin:{p}@db:5432/dragonbank')"] interval: 30s timeout: 10s retries: 3 @@ -102,4 +108,8 @@ networks: dragonbank-backend-net: driver: bridge dragonbank-frontend-net: - driver: bridge \ No newline at end of file + driver: bridge + +secrets: + db_password: + file: ./secrets/db_password.txt \ No newline at end of file diff --git a/DragonBank/frontend/app.py b/DragonBank/frontend/app.py index 0ee60de..fff3ee3 100644 --- a/DragonBank/frontend/app.py +++ b/DragonBank/frontend/app.py @@ -148,6 +148,76 @@ def dashboard(): ) +# ============================================ +# ROUTES - PROFIL +# ============================================ +@app.route('/profile', methods=['GET', 'POST']) +@login_required +def profile(): + """Page de profil pour gerer la securite et les infos persos.""" + token = session['token'] + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'update_profile': + phone = request.form.get('phone', '') + address = request.form.get('address', '') + + data, status = api_request('PUT', '/api/user/profile', { + 'phone': phone, + 'address': address + }, token=token) + + if status == 200: + flash('Informations personnelles mises a jour !', 'success') + else: + flash(data.get('error', 'Erreur lors de la mise a jour'), 'danger') + + elif action == 'update_password': + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + if new_password != confirm_password: + flash('Les nouveaux mots de passe ne correspondent pas', 'danger') + else: + data, status = api_request('PUT', '/api/user/security/password', { + 'current_password': current_password, + 'new_password': new_password + }, token=token) + + if status == 200: + flash('Mot de passe mis a jour avec succes !', 'success') + else: + flash(data.get('error', 'Erreur'), 'danger') + + elif action == 'update_email': + password = request.form.get('password') + new_email = request.form.get('new_email') + + data, status = api_request('PUT', '/api/user/security/email', { + 'password': password, + 'new_email': new_email + }, token=token) + + if status == 200: + flash('Email mis a jour avec succes. Vous devez vous reconnecter pour valider les changements.', 'success') + session.clear() + return redirect(url_for('login')) + else: + flash(data.get('error', 'Erreur'), 'danger') + + # Recuperation complete du profil et des comptes + profile_data, status_profile = api_request('GET', '/api/user/profile', token=token) + accounts_data, status_accounts = api_request('GET', '/api/accounts', token=token) + + user_profile = profile_data.get('user', session.get('user', {})) if status_profile == 200 else session.get('user', {}) + accounts_list = accounts_data.get('accounts', []) if status_accounts == 200 else [] + + return render_template('profile.html', user_profile=user_profile, accounts=accounts_list) + + # ============================================ # ROUTES - COMPTES # ============================================ @@ -349,6 +419,84 @@ def transactions(): ) +# ============================================ +# ROUTES - EXPORT CSV +# ============================================ +@app.route('/transactions/export') +@login_required +def export_transactions(): + """Redirige vers le backend pour tĂ©lĂ©charger le CSV des transactions.""" + import requests as req + token = session['token'] + account_id = request.args.get('account_id', '') + + endpoint = f"{BACKEND_URL}/api/transactions/export" + if account_id: + endpoint += f"?account_id={account_id}" + + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + try: + resp = req.get(endpoint, headers=headers, timeout=15) + if resp.status_code == 200: + from flask import Response + return Response( + resp.content, + mimetype='text/csv', + headers={ + 'Content-Disposition': 'attachment; filename="dragonbank_transactions.csv"' + } + ) + else: + flash('Erreur lors de la gĂ©nĂ©ration du CSV', 'danger') + return redirect(url_for('transactions')) + except Exception as e: + flash('Impossible de contacter le serveur', 'danger') + return redirect(url_for('transactions')) + + +# ============================================ +# ROUTES - SIMULATEUR D'ÉPARGNE +# ============================================ +@app.route('/simulator', methods=['GET', 'POST']) +@login_required +def simulator(): + """Simulateur de croissance de l'Ă©pargne avec graphique.""" + token = session['token'] + resultat = None + + # RĂ©cupĂ©ration des comptes Ă©pargne pour prĂ©-remplir le simulateur + accounts_data, _ = api_request('GET', '/api/accounts', token=token) + comptes_epargne = [ + a for a in accounts_data.get('accounts', []) + if a.get('account_type') in ('livret_a', 'assurance_vie') + ] + + if request.method == 'POST': + form_data = { + 'capital_initial': float(request.form.get('capital_initial', 0)), + 'taux_annuel': float(request.form.get('taux_annuel', 3)), + 'duree_annees': int(request.form.get('duree_annees', 10)), + 'versement_mensuel': float(request.form.get('versement_mensuel', 0)) + } + + data, status = api_request('POST', '/api/simulator', data=form_data, token=token) + + if status == 200: + resultat = data + else: + flash(data.get('error', 'Erreur de simulation'), 'danger') + + return render_template('simulator.html', + user=session.get('user', {}), + comptes_epargne=comptes_epargne, + resultat=resultat + ) + + # ============================================ # DÉMARRAGE # ============================================ diff --git a/DragonBank/frontend/static/style.css b/DragonBank/frontend/static/style.css index 2d06292..8dc56f6 100644 --- a/DragonBank/frontend/static/style.css +++ b/DragonBank/frontend/static/style.css @@ -1,565 +1,844 @@ -/* ============================================ - DragonBank - Styles CSS - ============================================ */ +/* ============================================================ + DragonBank — Design System + Inspired by Revolut: clean, minimal, premium fintech UI + Font: DM Sans (display) + DM Mono (numbers) + ============================================================ */ - :root { - --dragon-primary: #1a1a2e; - --dragon-secondary: #16213e; - --dragon-accent: #e94560; - --dragon-gold: #f5a623; - --dragon-success: #00b894; - --dragon-info: #0984e3; - --dragon-warning: #fdcb6e; - --dragon-danger: #d63031; - --dragon-light: #f8f9fa; - --dragon-dark: #0f0f23; - --dragon-gradient: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); - --dragon-card-bg: #ffffff; - --dragon-text: #2d3436; - --dragon-text-light: #636e72; - --dragon-border: #dfe6e9; - --dragon-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - --dragon-shadow-hover: 0 8px 25px rgba(0, 0, 0, 0.15); - --transition: all 0.3s ease; +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&family=DM+Mono:wght@300;400;500&display=swap'); + +/* ============================================================ + DESIGN TOKENS + ============================================================ */ +:root { + --c-bg: #f7f7f8; + --c-surface: #ffffff; + --c-surface-2: #f2f2f4; + --c-surface-3: #eaeaee; + --c-border: #e4e4e8; + --c-border-soft: #ededf0; + + --c-text-primary: #0d0d0d; + --c-text-secondary: #6b6b7a; + --c-text-tertiary: #9999a8; + --c-text-inverse: #ffffff; + + --c-brand: #191c3a; + --c-brand-mid: #2c3070; + --c-brand-light: #7b7ff5; + --c-accent: #6c63ff; + --c-accent-soft: rgba(108, 99, 255, 0.08); + --c-accent-hover: #5850e8; + + --c-green: #00c896; + --c-green-soft: rgba(0, 200, 150, 0.08); + --c-red: #ff4d6d; + --c-red-soft: rgba(255, 77, 109, 0.08); + --c-amber: #f59e0b; + --c-amber-soft: rgba(245, 158, 11, 0.08); + --c-blue: #3b82f6; + --c-blue-soft: rgba(59, 130, 246, 0.08); + + --c-courant: #3b82f6; + --c-livret: #00c896; + --c-assurance: #f59e0b; + + --shadow-xs: 0 1px 2px rgba(0,0,0,0.04); + --shadow-sm: 0 1px 4px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.04); + --shadow-md: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06); + --shadow-xl: 0 20px 60px rgba(0,0,0,0.14), 0 4px 16px rgba(0,0,0,0.08); + + --r-sm: 8px; + --r-md: 12px; + --r-lg: 16px; + --r-xl: 20px; + --r-2xl: 24px; + --r-full: 9999px; + + --font-body: 'DM Sans', system-ui, sans-serif; + --font-mono: 'DM Mono', 'Courier New', monospace; + + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --dur-fast: 120ms; + --dur-base: 200ms; + --dur-slow: 350ms; } -* { +/* ============================================================ + RESET & BASE + ============================================================ */ +*, *::before, *::after { + box-sizing: border-box; margin: 0; padding: 0; - box-sizing: border-box; +} + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background-color: #f0f2f5; - color: var(--dragon-text); + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.6; + color: var(--c-text-primary); + background-color: var(--c-bg); min-height: 100vh; } -/* ============================================ - NAVBAR - ============================================ */ +a { + color: var(--c-accent); + text-decoration: none; + transition: color var(--dur-fast) var(--ease-out); +} +a:hover { color: var(--c-accent-hover); } + +/* ============================================================ + NAVIGATION + ============================================================ */ .navbar { - background: var(--dragon-gradient) !important; - padding: 0.8rem 1.5rem; - box-shadow: 0 2px 20px rgba(0, 0, 0, 0.3); + background: var(--c-surface) !important; + border-bottom: 1px solid var(--c-border) !important; + padding: 0 24px !important; + height: 60px; + box-shadow: var(--shadow-xs) !important; + position: sticky; + top: 0; + z-index: 1000; } .navbar-brand { - font-size: 1.5rem; - font-weight: 800; - letter-spacing: 1px; + font-size: 1.0625rem !important; + font-weight: 700 !important; + color: var(--c-text-primary) !important; + letter-spacing: -0.3px; + display: flex; + align-items: center; + gap: 8px; } -.navbar-brand .dragon-icon { - font-size: 1.8rem; - margin-right: 8px; +.navbar-brand .dragon-icon { font-size: 1.2rem; margin-right: 0; } + +.navbar-nav .nav-link { + color: var(--c-text-secondary) !important; + font-size: 0.875rem !important; + font-weight: 500 !important; + padding: 6px 12px !important; + border-radius: var(--r-sm) !important; + transition: color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out) !important; + margin: 0 2px !important; } -.nav-link { - color: rgba(255, 255, 255, 0.85) !important; - font-weight: 500; - padding: 0.5rem 1rem !important; - border-radius: 8px; - transition: var(--transition); - margin: 0 2px; +.navbar-nav .nav-link:hover { + color: var(--c-text-primary) !important; + background: var(--c-surface-2) !important; } -.nav-link:hover, .nav-link.active { - color: #fff !important; - background: rgba(255, 255, 255, 0.15); +.navbar-nav .nav-link.active { + color: var(--c-accent) !important; + background: var(--c-accent-soft) !important; + font-weight: 600 !important; } -.nav-link i { - margin-right: 5px; +.navbar-nav .nav-link i { margin-right: 4px; font-size: 0.875rem; } + +.dropdown-menu { + border: 1px solid var(--c-border) !important; + border-radius: var(--r-md) !important; + box-shadow: var(--shadow-md) !important; + padding: 6px !important; + font-size: 0.875rem !important; + margin-top: 8px !important; } +.dropdown-item { + border-radius: var(--r-sm) !important; + padding: 7px 12px !important; + color: var(--c-text-secondary) !important; + font-weight: 500 !important; + transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out) !important; +} + +.dropdown-item:hover { + background: var(--c-surface-2) !important; + color: var(--c-text-primary) !important; +} + +.dropdown-item i { margin-right: 8px; opacity: 0.6; width: 14px; text-align: center; } + .user-badge { - background: rgba(245, 166, 35, 0.2); - border: 1px solid var(--dragon-gold); - color: var(--dragon-gold); - padding: 5px 15px; - border-radius: 20px; + display: inline-flex; + align-items: center; + gap: 7px; + background: var(--c-surface-2); + border: 1px solid var(--c-border); + color: var(--c-text-primary); + padding: 5px 14px; + border-radius: var(--r-full); font-weight: 600; - font-size: 0.85rem; + font-size: 0.8125rem; } -/* ============================================ - LOGIN PAGE - ============================================ */ +/* ============================================================ + LOGIN / REGISTER + ============================================================ */ .login-page { min-height: 100vh; - background: var(--dragon-gradient); + background: var(--c-bg); display: flex; align-items: center; justify-content: center; - padding: 20px; + padding: 24px; + position: relative; + overflow: hidden; +} + +.login-page::before { + content: ''; + position: absolute; + top: -200px; right: -200px; + width: 600px; height: 600px; + background: radial-gradient(circle, rgba(108,99,255,0.06) 0%, transparent 70%); + pointer-events: none; +} + +.login-page::after { + content: ''; + position: absolute; + bottom: -150px; left: -150px; + width: 500px; height: 500px; + background: radial-gradient(circle, rgba(0,200,150,0.04) 0%, transparent 70%); + pointer-events: none; } .login-container { width: 100%; - max-width: 450px; + max-width: 420px; + position: relative; + z-index: 1; } .login-header { text-align: center; - margin-bottom: 2rem; -} - -.login-header h1 { - color: #fff; - font-size: 2.5rem; - font-weight: 800; - margin-bottom: 0.5rem; + margin-bottom: 32px; } .login-header .dragon-logo { - font-size: 4rem; + font-size: 2.5rem; display: block; - margin-bottom: 1rem; + margin-bottom: 16px; +} + +.login-header h1 { + font-size: 1.75rem; + font-weight: 800; + color: var(--c-text-primary); + letter-spacing: -0.5px; + margin-bottom: 4px; } .login-header p { - color: rgba(255, 255, 255, 0.7); - font-size: 1.1rem; + color: var(--c-text-secondary); + font-size: 0.9375rem; } .login-card { - background: white; - border-radius: 20px; - padding: 2.5rem; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + background: var(--c-surface); + border-radius: var(--r-2xl); + padding: 32px; + box-shadow: var(--shadow-lg); + border: 1px solid var(--c-border-soft); } .login-card h2 { - color: var(--dragon-primary); - margin-bottom: 1.5rem; - text-align: center; + font-size: 1.1875rem; font-weight: 700; + color: var(--c-text-primary); + margin-bottom: 24px; + letter-spacing: -0.3px; } -/* ============================================ +/* ============================================================ FORMS - ============================================ */ -.form-group { - margin-bottom: 1.2rem; -} + ============================================================ */ +.form-group { margin-bottom: 20px; } .form-label { + display: block; + font-size: 0.8125rem; font-weight: 600; - color: var(--dragon-text); - margin-bottom: 0.4rem; - font-size: 0.9rem; + color: var(--c-text-secondary); + margin-bottom: 7px; + letter-spacing: 0.2px; + text-transform: uppercase; } -.form-control { - border: 2px solid var(--dragon-border); - border-radius: 10px; - padding: 12px 15px; - font-size: 1rem; - transition: var(--transition); +.form-control, +.form-select { + width: 100%; + padding: 11px 16px; + font-size: 0.9375rem; + font-family: var(--font-body); + font-weight: 400; + color: var(--c-text-primary); + background: var(--c-surface); + border: 1.5px solid var(--c-border); + border-radius: var(--r-md); + line-height: 1.5; + transition: border-color var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out); + -webkit-appearance: none; + appearance: none; } -.form-control:focus { - border-color: var(--dragon-accent); - box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.15); +.form-control::placeholder { color: var(--c-text-tertiary); } + +.form-control:hover, +.form-select:hover { border-color: #c8c8d0; } + +.form-control:focus, +.form-select:focus { + outline: none; + border-color: var(--c-accent); + box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.10); } .form-select { - border: 2px solid var(--dragon-border); - border-radius: 10px; - padding: 12px 15px; - font-size: 1rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%236b6b7a' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 9px; + padding-right: 36px; + cursor: pointer; } -.form-select:focus { - border-color: var(--dragon-accent); - box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.15); +.form-text, +small.text-muted { + font-size: 0.8125rem; + color: var(--c-text-tertiary); + margin-top: 5px; + display: block; } -/* ============================================ +/* ============================================================ BUTTONS - ============================================ */ + ============================================================ */ .btn-dragon { - background: var(--dragon-accent); - color: white; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 11px 24px; + font-family: var(--font-body); + font-size: 0.9375rem; + font-weight: 600; + color: #ffffff; + background: var(--c-brand); border: none; - padding: 12px 30px; - border-radius: 10px; - font-weight: 700; - font-size: 1rem; - transition: var(--transition); - text-transform: uppercase; - letter-spacing: 1px; + border-radius: var(--r-md); + cursor: pointer; + white-space: nowrap; + transition: background var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out); + letter-spacing: -0.1px; + text-decoration: none; } .btn-dragon:hover { - background: #d63051; - color: white; - transform: translateY(-2px); - box-shadow: 0 5px 20px rgba(233, 69, 96, 0.4); + background: var(--c-brand-mid); + color: #ffffff; + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(25, 28, 58, 0.25); } +.btn-dragon:active { transform: translateY(0); box-shadow: none; } +.btn-dragon.w-100 { width: 100%; } + .btn-dragon-outline { - background: transparent; - color: var(--dragon-accent); - border: 2px solid var(--dragon-accent); - padding: 10px 25px; - border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 10px 24px; + font-family: var(--font-body); + font-size: 0.9375rem; font-weight: 600; - transition: var(--transition); + color: var(--c-text-secondary); + background: transparent; + border: 1.5px solid var(--c-border); + border-radius: var(--r-md); + cursor: pointer; + white-space: nowrap; + transition: border-color var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); + text-decoration: none; } .btn-dragon-outline:hover { - background: var(--dragon-accent); - color: white; + border-color: var(--c-text-primary); + color: var(--c-text-primary); + background: var(--c-surface-2); } +.btn-dragon-outline.w-100 { width: 100%; } + .btn-gold { - background: var(--dragon-gold); - color: var(--dragon-dark); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 11px 24px; + font-family: var(--font-body); + font-size: 0.9375rem; + font-weight: 600; + color: var(--c-text-primary); + background: var(--c-amber); border: none; - padding: 12px 30px; - border-radius: 10px; - font-weight: 700; - transition: var(--transition); + border-radius: var(--r-md); + cursor: pointer; + transition: filter var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); + text-decoration: none; } -.btn-gold:hover { - background: #e09500; - transform: translateY(-2px); - box-shadow: 0 5px 20px rgba(245, 166, 35, 0.4); -} +.btn-gold:hover { filter: brightness(0.92); transform: translateY(-1px); color: var(--c-text-primary); } .btn-success-custom { - background: var(--dragon-success); - color: white; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 11px 24px; + font-family: var(--font-body); + font-size: 0.9375rem; + font-weight: 600; + color: #ffffff; + background: var(--c-green); border: none; - padding: 12px 30px; - border-radius: 10px; - font-weight: 700; - transition: var(--transition); + border-radius: var(--r-md); + cursor: pointer; + transition: filter var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); + text-decoration: none; } -.btn-success-custom:hover { - background: #00a383; - color: white; - transform: translateY(-2px); +.btn-success-custom:hover { filter: brightness(0.9); transform: translateY(-1px); color: #ffffff; } + +.btn-sm { + padding: 6px 14px !important; + font-size: 0.8125rem !important; + border-radius: var(--r-sm) !important; } -/* ============================================ - DASHBOARD - ============================================ */ +.btn-danger { + background: var(--c-red) !important; + border: none !important; + border-radius: var(--r-sm) !important; + font-weight: 600 !important; + font-size: 0.8125rem !important; + transition: filter var(--dur-fast) var(--ease-out) !important; +} + +.btn-danger:hover { filter: brightness(0.88) !important; } + +/* ============================================================ + LAYOUT + ============================================================ */ .main-content { - padding: 2rem; - max-width: 1400px; + padding: 32px; + max-width: 1280px; margin: 0 auto; } .page-header { - margin-bottom: 2rem; + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid var(--c-border); } .page-header h1 { - color: var(--dragon-primary); + font-size: 1.625rem; font-weight: 800; - font-size: 2rem; + color: var(--c-text-primary); + letter-spacing: -0.5px; + line-height: 1.2; +} + +.page-header h1 i { + color: var(--c-accent); + margin-right: 10px; + font-size: 1.375rem; } .page-header p { - color: var(--dragon-text-light); - font-size: 1.1rem; + color: var(--c-text-secondary); + font-size: 0.9375rem; + margin-top: 4px; } -/* ============================================ +/* ============================================================ + SECTION CARD + ============================================================ */ +.section-card { + background: var(--c-surface); + border-radius: var(--r-xl); + padding: 24px; + border: 1px solid var(--c-border); + box-shadow: var(--shadow-sm); + margin-bottom: 20px; + transition: box-shadow var(--dur-base) var(--ease-out); +} + +.section-card:hover { box-shadow: var(--shadow-md); } + +.section-card .section-title { + font-size: 1rem; + font-weight: 700; + color: var(--c-text-primary); + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--c-border); + letter-spacing: -0.2px; +} + +/* ============================================================ STAT CARDS - ============================================ */ + ============================================================ */ .stat-card { - background: white; - border-radius: 16px; - padding: 1.5rem; - box-shadow: var(--dragon-shadow); - transition: var(--transition); - border: none; + background: var(--c-surface); + border-radius: var(--r-xl); + padding: 24px; + border: 1px solid var(--c-border); + box-shadow: var(--shadow-sm); + transition: box-shadow var(--dur-base) var(--ease-out), + transform var(--dur-base) var(--ease-out); position: relative; overflow: hidden; } +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + .stat-card::before { content: ''; position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; + top: 0; left: 0; right: 0; + height: 3px; + border-radius: var(--r-xl) var(--r-xl) 0 0; + opacity: 0; + transition: opacity var(--dur-base) var(--ease-out); } -.stat-card:hover { - transform: translateY(-5px); - box-shadow: var(--dragon-shadow-hover); -} - -.stat-card.primary::before { background: var(--dragon-accent); } -.stat-card.success::before { background: var(--dragon-success); } -.stat-card.info::before { background: var(--dragon-info); } -.stat-card.warning::before { background: var(--dragon-gold); } +.stat-card:hover::before { opacity: 1; } +.stat-card.primary::before { background: var(--c-accent); } +.stat-card.success::before { background: var(--c-green); } +.stat-card.info::before { background: var(--c-blue); } +.stat-card.warning::before { background: var(--c-amber); } .stat-card .stat-icon { - width: 60px; - height: 60px; - border-radius: 15px; + width: 44px; + height: 44px; + border-radius: var(--r-md); display: flex; align-items: center; justify-content: center; - font-size: 1.5rem; - margin-bottom: 1rem; + font-size: 1.125rem; + margin-bottom: 16px; } -.stat-card.primary .stat-icon { background: rgba(233, 69, 96, 0.1); color: var(--dragon-accent); } -.stat-card.success .stat-icon { background: rgba(0, 184, 148, 0.1); color: var(--dragon-success); } -.stat-card.info .stat-icon { background: rgba(9, 132, 227, 0.1); color: var(--dragon-info); } -.stat-card.warning .stat-icon { background: rgba(245, 166, 35, 0.1); color: var(--dragon-gold); } +.stat-card.primary .stat-icon { background: var(--c-accent-soft); color: var(--c-accent); } +.stat-card.success .stat-icon { background: var(--c-green-soft); color: var(--c-green); } +.stat-card.info .stat-icon { background: var(--c-blue-soft); color: var(--c-blue); } +.stat-card.warning .stat-icon { background: var(--c-amber-soft); color: var(--c-amber); } .stat-card .stat-value { - font-size: 1.8rem; - font-weight: 800; - color: var(--dragon-primary); + font-family: var(--font-mono); + font-size: 1.75rem; + font-weight: 500; + color: var(--c-text-primary); line-height: 1; + letter-spacing: -1px; } .stat-card .stat-label { - font-size: 0.85rem; - color: var(--dragon-text-light); - margin-top: 0.3rem; + font-size: 0.8125rem; + color: var(--c-text-secondary); + margin-top: 8px; font-weight: 500; } -/* ============================================ +/* ============================================================ ACCOUNT CARDS - ============================================ */ + ============================================================ */ .account-card { - background: white; - border-radius: 16px; - padding: 1.5rem; - box-shadow: var(--dragon-shadow); - transition: var(--transition); - margin-bottom: 1rem; - border-left: 5px solid; + background: var(--c-surface); + border-radius: var(--r-xl); + padding: 24px; + border: 1px solid var(--c-border); + box-shadow: var(--shadow-sm); + transition: box-shadow var(--dur-base) var(--ease-out), + transform var(--dur-base) var(--ease-out); + margin-bottom: 16px; + position: relative; + overflow: hidden; } +.account-card::after { + content: ''; + position: absolute; + top: 0; left: 0; + width: 4px; + height: 100%; + border-radius: var(--r-xl) 0 0 var(--r-xl); +} + +.account-card.courant::after { background: var(--c-courant); } +.account-card.livret_a::after { background: var(--c-livret); } +.account-card.assurance_vie::after { background: var(--c-assurance); } + .account-card:hover { - transform: translateY(-3px); - box-shadow: var(--dragon-shadow-hover); + transform: translateY(-2px); + box-shadow: var(--shadow-md); } -.account-card.courant { border-left-color: var(--dragon-info); } -.account-card.livret_a { border-left-color: var(--dragon-success); } -.account-card.assurance_vie { border-left-color: var(--dragon-gold); } - .account-type-badge { - display: inline-block; - padding: 4px 12px; - border-radius: 20px; + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: var(--r-full); font-size: 0.75rem; - font-weight: 700; + font-weight: 600; + letter-spacing: 0.3px; text-transform: uppercase; - letter-spacing: 1px; } -.account-type-badge.courant { background: rgba(9, 132, 227, 0.1); color: var(--dragon-info); } -.account-type-badge.livret_a { background: rgba(0, 184, 148, 0.1); color: var(--dragon-success); } -.account-type-badge.assurance_vie { background: rgba(245, 166, 35, 0.1); color: var(--dragon-gold); } +.account-type-badge.courant { background: rgba(59,130,246,0.08); color: var(--c-courant); } +.account-type-badge.livret_a { background: rgba(0,200,150,0.08); color: var(--c-livret); } +.account-type-badge.assurance_vie { background: rgba(245,158,11,0.08); color: var(--c-assurance); } .account-balance { + font-family: var(--font-mono); font-size: 2rem; - font-weight: 800; - color: var(--dragon-primary); + font-weight: 500; + color: var(--c-text-primary); + letter-spacing: -1.5px; + line-height: 1.1; } .account-number { - font-family: 'Courier New', monospace; - color: var(--dragon-text-light); - font-size: 0.9rem; - letter-spacing: 2px; + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--c-text-tertiary); + letter-spacing: 1.5px; + font-weight: 300; } -/* ============================================ +/* ============================================================ TABLES - ============================================ */ + ============================================================ */ .custom-table { - background: white; - border-radius: 16px; + background: var(--c-surface); + border-radius: var(--r-xl); overflow: hidden; - box-shadow: var(--dragon-shadow); + border: 1px solid var(--c-border); + box-shadow: var(--shadow-sm); } -.custom-table .table { - margin-bottom: 0; +.custom-table .table { margin-bottom: 0; font-size: 0.875rem; } + +.custom-table .table thead, +.table thead { background: var(--c-surface-2); } + +.custom-table .table thead th, +.table thead th { + font-size: 0.75rem !important; + font-weight: 700 !important; + color: var(--c-text-tertiary) !important; + text-transform: uppercase !important; + letter-spacing: 0.6px !important; + padding: 14px 20px !important; + border: none !important; + border-bottom: 1px solid var(--c-border) !important; + background: var(--c-surface-2) !important; } -.custom-table .table thead { - background: var(--dragon-primary); - color: white; +.custom-table .table tbody td, +.table tbody td { + padding: 14px 20px !important; + vertical-align: middle !important; + border-bottom: 1px solid var(--c-border-soft) !important; + font-size: 0.875rem; + color: var(--c-text-primary); } -.custom-table .table thead th { - font-weight: 600; - padding: 1rem; - border: none; - font-size: 0.85rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} +.custom-table .table tbody tr:last-child td, +.table tbody tr:last-child td { border-bottom: none !important; } -.custom-table .table tbody td { - padding: 1rem; - vertical-align: middle; - border-bottom: 1px solid var(--dragon-border); +.table-hover tbody tr, +.custom-table .table tbody tr { + transition: background var(--dur-fast) var(--ease-out); } +.table-hover tbody tr:hover, .custom-table .table tbody tr:hover { - background-color: rgba(233, 69, 96, 0.03); + background-color: var(--c-surface-2) !important; } -.transaction-amount.credit { - color: var(--dragon-success); - font-weight: 700; +.transaction-amount.credit { color: var(--c-green); font-weight: 600; } +.transaction-amount.debit { color: var(--c-red); font-weight: 600; } + +/* ============================================================ + BADGES & STATUS + ============================================================ */ +.badge { + font-size: 0.71875rem !important; + font-weight: 600 !important; + padding: 3px 9px !important; + border-radius: var(--r-full) !important; + letter-spacing: 0.2px !important; } -.transaction-amount.debit { - color: var(--dragon-danger); - font-weight: 700; -} +.bg-primary { background: var(--c-blue) !important; } +.bg-success { background: var(--c-green) !important; } +.bg-danger { background: var(--c-red) !important; } +.bg-warning { background: var(--c-amber) !important; } +.bg-info { background: var(--c-brand-light) !important; } +.bg-secondary { background: var(--c-text-tertiary) !important; } -/* ============================================ - TRANSACTION STATUS BADGES - ============================================ */ .status-badge { - padding: 4px 12px; - border-radius: 20px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--r-full); font-size: 0.75rem; - font-weight: 700; + font-weight: 600; + white-space: nowrap; } -.status-badge.completed { background: rgba(0, 184, 148, 0.1); color: var(--dragon-success); } -.status-badge.pending { background: rgba(253, 203, 110, 0.2); color: #e17055; } -.status-badge.failed { background: rgba(214, 48, 49, 0.1); color: var(--dragon-danger); } +.status-badge.completed { background: var(--c-green-soft); color: var(--c-green); } +.status-badge.pending { background: var(--c-amber-soft); color: var(--c-amber); } +.status-badge.failed { background: var(--c-red-soft); color: var(--c-red); } +.status-badge i { font-size: 0.6875rem; } -/* ============================================ - SECTIONS - ============================================ */ -.section-card { - background: white; - border-radius: 16px; - padding: 1.5rem; - box-shadow: var(--dragon-shadow); - margin-bottom: 1.5rem; -} - -.section-card .section-title { - font-size: 1.2rem; - font-weight: 700; - color: var(--dragon-primary); - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid var(--dragon-border); -} - -/* ============================================ +/* ============================================================ ALERTS - ============================================ */ + ============================================================ */ .alert { - border: none; - border-radius: 12px; - padding: 1rem 1.5rem; - font-weight: 500; + border: none !important; + border-radius: var(--r-md) !important; + padding: 14px 18px !important; + font-size: 0.9375rem !important; + font-weight: 500 !important; + display: flex !important; + align-items: center !important; + gap: 10px !important; } +.alert i { flex-shrink: 0; } + .alert-success { - background: rgba(0, 184, 148, 0.1); - color: #00a383; - border-left: 4px solid var(--dragon-success); + background: var(--c-green-soft) !important; + color: #00a37c !important; + border-left: 3px solid var(--c-green) !important; } .alert-danger { - background: rgba(214, 48, 49, 0.1); - color: #c0392b; - border-left: 4px solid var(--dragon-danger); + background: var(--c-red-soft) !important; + color: #d93654 !important; + border-left: 3px solid var(--c-red) !important; } .alert-warning { - background: rgba(253, 203, 110, 0.15); - color: #e17055; - border-left: 4px solid var(--dragon-gold); + background: var(--c-amber-soft) !important; + color: #c47d00 !important; + border-left: 3px solid var(--c-amber) !important; } .alert-info { - background: rgba(9, 132, 227, 0.1); - color: var(--dragon-info); - border-left: 4px solid var(--dragon-info); + background: var(--c-blue-soft) !important; + color: #2266cc !important; + border-left: 3px solid var(--c-blue) !important; } -/* ============================================ - RESPONSIVE - ============================================ */ -@media (max-width: 768px) { - .main-content { - padding: 1rem; - } - - .stat-card .stat-value { - font-size: 1.4rem; - } - - .account-balance { - font-size: 1.5rem; - } - - .login-card { - padding: 1.5rem; - } - - .login-header h1 { - font-size: 2rem; - } -} +.alert-link { font-weight: 700 !important; text-decoration: underline !important; } +.btn-close { margin-left: auto !important; flex-shrink: 0 !important; opacity: 0.4 !important; } +.btn-close:hover { opacity: 0.7 !important; } -/* ============================================ - ANIMATIONS - ============================================ */ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.fade-in { - animation: fadeInUp 0.5s ease forwards; -} - -.delay-1 { animation-delay: 0.1s; } -.delay-2 { animation-delay: 0.2s; } -.delay-3 { animation-delay: 0.3s; } -.delay-4 { animation-delay: 0.4s; } - -/* ============================================ +/* ============================================================ FOOTER - ============================================ */ + ============================================================ */ .footer { - background: var(--dragon-primary); - color: rgba(255, 255, 255, 0.6); + background: var(--c-surface); + border-top: 1px solid var(--c-border); + color: var(--c-text-tertiary); text-align: center; - padding: 1.5rem; - margin-top: 3rem; - font-size: 0.85rem; + padding: 24px 32px; + margin-top: 48px; + font-size: 0.8125rem; } -.footer a { - color: var(--dragon-gold); - text-decoration: none; -} \ No newline at end of file +.footer a { color: var(--c-text-secondary); font-weight: 500; } +.footer a:hover { color: var(--c-text-primary); } + +/* ============================================================ + ANIMATIONS + ============================================================ */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.fade-in { animation: fadeUp var(--dur-slow) var(--ease-out) both; } +.delay-1 { animation-delay: 60ms; } +.delay-2 { animation-delay: 120ms; } +.delay-3 { animation-delay: 180ms; } +.delay-4 { animation-delay: 240ms; } + +/* ============================================================ + UTILITIES + ============================================================ */ +code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--c-surface-2); + border: 1px solid var(--c-border); + padding: 1px 6px; + border-radius: var(--r-sm); + color: var(--c-text-primary); +} + +.text-muted { color: var(--c-text-secondary) !important; } +.text-success { color: var(--c-green) !important; } +.text-danger { color: var(--c-red) !important; } +.text-warning { color: var(--c-amber) !important; } +.text-info { color: var(--c-blue) !important; } + +/* ============================================================ + RESPONSIVE + ============================================================ */ +@media (max-width: 1024px) { + .main-content { padding: 24px; } +} + +@media (max-width: 768px) { + .navbar { padding: 0 16px !important; } + .main-content { padding: 20px 16px; } + .page-header h1 { font-size: 1.375rem; } + .stat-card .stat-value { font-size: 1.375rem; } + .account-balance { font-size: 1.625rem; } + .login-card { padding: 24px; } + .section-card { padding: 20px; border-radius: var(--r-lg); } +} + +@media (max-width: 480px) { + .main-content { padding: 16px 12px; } + .btn-dragon, + .btn-dragon-outline, + .btn-gold, + .btn-success-custom { padding: 10px 18px; font-size: 0.875rem; } +} diff --git a/DragonBank/frontend/templates/base.html b/DragonBank/frontend/templates/base.html index 8b4bf48..4a3d13f 100644 --- a/DragonBank/frontend/templates/base.html +++ b/DragonBank/frontend/templates/base.html @@ -63,14 +63,20 @@ Historique +