From 3cf823305446a5adef28262fa589e00a5ba7855b Mon Sep 17 00:00:00 2001 From: yolou Date: Wed, 18 Mar 2026 16:20:09 +0100 Subject: [PATCH] first commit --- DragonBank/backend/Dockerfile | 43 + DragonBank/backend/app.py | 946 ++++++++++++++++++ DragonBank/backend/requirements.txt | 6 + DragonBank/db/Dockerfile | 14 + DragonBank/db/init.sql | 232 +++++ DragonBank/docker-compose.yml | 105 ++ DragonBank/frontend/Dockerfile | 30 + DragonBank/frontend/app.py | 357 +++++++ DragonBank/frontend/requirements.txt | 2 + DragonBank/frontend/static/style.css | 565 +++++++++++ DragonBank/frontend/templates/accounts.html | 81 ++ .../frontend/templates/add_beneficiary.html | 70 ++ DragonBank/frontend/templates/base.html | 123 +++ DragonBank/frontend/templates/dashboard.html | 154 +++ DragonBank/frontend/templates/login.html | 68 ++ DragonBank/frontend/templates/register.html | 102 ++ .../frontend/templates/transfer_external.html | 98 ++ DragonBank/interests/Dockerfile | 29 + DragonBank/interests/app.py | 191 ++++ DragonBank/interests/requirements.txt | 2 + DragonBank/secrets/db_password.txt | 1 + 21 files changed, 3219 insertions(+) create mode 100644 DragonBank/backend/Dockerfile create mode 100644 DragonBank/backend/app.py create mode 100644 DragonBank/backend/requirements.txt create mode 100644 DragonBank/db/Dockerfile create mode 100644 DragonBank/db/init.sql create mode 100644 DragonBank/docker-compose.yml create mode 100644 DragonBank/frontend/Dockerfile create mode 100644 DragonBank/frontend/app.py create mode 100644 DragonBank/frontend/requirements.txt create mode 100644 DragonBank/frontend/static/style.css create mode 100644 DragonBank/frontend/templates/accounts.html create mode 100644 DragonBank/frontend/templates/add_beneficiary.html create mode 100644 DragonBank/frontend/templates/base.html create mode 100644 DragonBank/frontend/templates/dashboard.html create mode 100644 DragonBank/frontend/templates/login.html create mode 100644 DragonBank/frontend/templates/register.html create mode 100644 DragonBank/frontend/templates/transfer_external.html create mode 100644 DragonBank/interests/Dockerfile create mode 100644 DragonBank/interests/app.py create mode 100644 DragonBank/interests/requirements.txt create mode 100644 DragonBank/secrets/db_password.txt diff --git a/DragonBank/backend/Dockerfile b/DragonBank/backend/Dockerfile new file mode 100644 index 0000000..ee7af15 --- /dev/null +++ b/DragonBank/backend/Dockerfile @@ -0,0 +1,43 @@ +FROM python:3.12-slim + +LABEL maintainer="DragonBank Team" +LABEL description="DragonBank Backend API" + +# Variables d'environnement +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Répertoire de travail +WORKDIR /app + +# Installation des dépendances système +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copie des fichiers de dépendances +COPY requirements.txt . + +# Installation des dépendances Python +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copie du code source +COPY app.py . + +# Création d'un utilisateur non-root (sécurité) +RUN adduser --disabled-password --gecos '' appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Exposition du port +EXPOSE 5000 + +# Healthcheck +HEALTHCHECK --interval=15s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:5000/api/health || exit 1 + +# Commande de démarrage +CMD ["python", "app.py"] \ No newline at end of file diff --git a/DragonBank/backend/app.py b/DragonBank/backend/app.py new file mode 100644 index 0000000..bb0e23d --- /dev/null +++ b/DragonBank/backend/app.py @@ -0,0 +1,946 @@ +""" +DragonBank - Backend API +======================== +API REST pour la gestion bancaire complète. +""" + +import os +import uuid +import decimal +from datetime import datetime, timedelta, timezone +from functools import wraps + +import jwt +import bcrypt +import psycopg2 +import psycopg2.extras +from flask import Flask, request, jsonify +from flask_cors import CORS + +# ============================================ +# CONFIGURATION +# ============================================ +app = Flask(__name__) +CORS(app) + +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') + +# ============================================ +# 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 + + +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 + + +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.""" + try: + conn = get_db_connection() + cur = conn.cursor() + cur.execute('SELECT 1') + cur.close() + conn.close() + return jsonify({ + 'status': 'healthy', + 'service': 'DragonBank Backend API', + 'database': 'connected', + 'timestamp': datetime.now(timezone.utc).isoformat() + }), 200 + except Exception as e: + return jsonify({ + 'status': 'unhealthy', + 'error': str(e) + }), 500 + + +# ============================================ +# ROUTES - 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() + 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 + + except Exception as e: + conn.rollback() + return jsonify({'error': f'Erreur lors de l\'inscription: {str(e)}'}), 500 + finally: + conn.close() + + +@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() + 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 + + except Exception as e: + conn.rollback() + return jsonify({'error': f'Erreur de connexion: {str(e)}'}), 500 + finally: + conn.close() + + +# ============================================ +# ROUTES - 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() + 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() + + +# ============================================ +# ROUTES - 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() + 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() + + +@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() + 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 + + except Exception as e: + conn.rollback() + return jsonify({'error': f'Erreur lors de l\'ouverture du compte: {str(e)}'}), 500 + finally: + conn.close() + + +@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() + 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() + + +# ============================================ +# ROUTES - BÉNÉFICIAIRES +# ============================================ +@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() + 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() + + +@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() + 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 + + except Exception as e: + conn.rollback() + return jsonify({'error': f'Erreur: {str(e)}'}), 500 + finally: + conn.close() + + +@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() + 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 + except Exception as e: + conn.rollback() + return jsonify({'error': str(e)}), 500 + finally: + conn.close() + + +# ============================================ +# ROUTES - VIREMENTS / TRANSACTIONS +# ============================================ +@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() + 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 + + except Exception as e: + conn.rollback() + return jsonify({'error': f'Erreur lors du virement: {str(e)}'}), 500 + finally: + conn.close() + + +@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() + 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 + + except Exception as e: + conn.rollback() + return jsonify({'error': f'Erreur lors du virement: {str(e)}'}), 500 + finally: + conn.close() + + +@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() + 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() + + return jsonify({ + 'message': f'Virement externe {"sortant" if direction == "outgoing" else "entrant"} de {amount}€ effectué', + 'transaction': serialize_row(transaction) + }), 200 + + except Exception as e: + conn.rollback() + return jsonify({'error': f'Erreur: {str(e)}'}), 500 + finally: + conn.close() + + +# ============================================ +# ROUTES - HISTORIQUE DES 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() + 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() + + +# ============================================ +# ROUTES - STATISTIQUES +# ============================================ +@app.route('/api/stats', methods=['GET']) +@token_required +def get_stats(current_user_id): + """Statistiques du compte utilisateur.""" + conn = get_db_connection() + 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() + + +# ============================================ +# DÉMARRAGE +# ============================================ +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 diff --git a/DragonBank/backend/requirements.txt b/DragonBank/backend/requirements.txt new file mode 100644 index 0000000..b41751e --- /dev/null +++ b/DragonBank/backend/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +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 diff --git a/DragonBank/db/Dockerfile b/DragonBank/db/Dockerfile new file mode 100644 index 0000000..86dab81 --- /dev/null +++ b/DragonBank/db/Dockerfile @@ -0,0 +1,14 @@ +FROM postgres:16-alpine + +LABEL maintainer="DragonBank Team" +LABEL description="DragonBank PostgreSQL Database" + +# Copie du script d'initialisation +COPY init.sql /docker-entrypoint-initdb.d/ + +# Exposition du port +EXPOSE 5432 + +# Healthcheck intégré +HEALTHCHECK --interval=10s --timeout=5s --retries=5 \ + CMD pg_isready -U dragonadmin -d dragonbank || exit 1 \ No newline at end of file diff --git a/DragonBank/db/init.sql b/DragonBank/db/init.sql new file mode 100644 index 0000000..1b0e5ba --- /dev/null +++ b/DragonBank/db/init.sql @@ -0,0 +1,232 @@ +-- ============================================ +-- DragonBank - Schéma de Base de Données +-- ============================================ + +-- Extension pour UUID +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================ +-- TABLE: Utilisateurs +-- ============================================ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + phone VARCHAR(20), + address TEXT, + date_of_birth DATE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP WITH TIME ZONE +); + +-- ============================================ +-- TABLE: Types de Comptes +-- ============================================ +CREATE TYPE account_type AS ENUM ('courant', 'livret_a', 'assurance_vie'); +CREATE TYPE account_status AS ENUM ('active', 'closed', 'frozen'); + +-- ============================================ +-- TABLE: Comptes Bancaires +-- ============================================ +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_number VARCHAR(20) UNIQUE NOT NULL, + account_type account_type NOT NULL DEFAULT 'courant', + balance DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + currency VARCHAR(3) DEFAULT 'EUR', + status account_status DEFAULT 'active', + interest_rate DECIMAL(5, 4) DEFAULT 0.0000, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT positive_balance CHECK (balance >= 0) +); + +-- ============================================ +-- TABLE: Bénéficiaires +-- ============================================ +CREATE TYPE beneficiary_status AS ENUM ('pending', 'approved', 'rejected'); + +CREATE TABLE IF NOT EXISTS beneficiaries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + beneficiary_name VARCHAR(200) NOT NULL, + bank_name VARCHAR(200) DEFAULT 'DragonBank', + account_number VARCHAR(34) NOT NULL, + iban VARCHAR(34), + bic VARCHAR(11), + status beneficiary_status DEFAULT 'approved', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, account_number) +); + +-- ============================================ +-- TABLE: Transactions / Virements +-- ============================================ +CREATE TYPE transaction_type AS ENUM ( + 'virement_interne', + 'virement_entre_personnes', + 'virement_externe', + 'depot', + 'retrait', + 'interets' +); + +CREATE TYPE transaction_status AS ENUM ('pending', 'completed', 'failed', 'cancelled'); + +CREATE TABLE IF NOT EXISTS transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + from_account_id UUID REFERENCES accounts(id), + to_account_id UUID REFERENCES accounts(id), + transaction_type transaction_type NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + currency VARCHAR(3) DEFAULT 'EUR', + description TEXT, + status transaction_status DEFAULT 'pending', + reference VARCHAR(50) UNIQUE DEFAULT uuid_generate_v4()::text, + external_bank_name VARCHAR(200), + external_account_number VARCHAR(34), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + executed_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT positive_amount CHECK (amount > 0) +); + +-- ============================================ +-- TABLE: Historique des Intérêts +-- ============================================ +CREATE TABLE IF NOT EXISTS interest_history ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + amount DECIMAL(15, 2) NOT NULL, + rate DECIMAL(5, 4) NOT NULL, + balance_before DECIMAL(15, 2) NOT NULL, + balance_after DECIMAL(15, 2) NOT NULL, + calculated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- ============================================ +-- TABLE: Sessions (pour la sécurité) +-- ============================================ +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(500) NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + is_active BOOLEAN DEFAULT TRUE +); + +-- ============================================ +-- TABLE: Logs d'audit +-- ============================================ +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id), + action VARCHAR(100) NOT NULL, + details JSONB, + ip_address VARCHAR(45), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- ============================================ +-- INDEX pour performance +-- ============================================ +CREATE INDEX idx_accounts_user_id ON accounts(user_id); +CREATE INDEX idx_transactions_from ON transactions(from_account_id); +CREATE INDEX idx_transactions_to ON transactions(to_account_id); +CREATE INDEX idx_transactions_status ON transactions(status); +CREATE INDEX idx_transactions_created ON transactions(created_at); +CREATE INDEX idx_beneficiaries_user_id ON beneficiaries(user_id); +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_token ON sessions(token); +CREATE INDEX idx_audit_user_id ON audit_logs(user_id); + +-- ============================================ +-- FONCTION: Générer un numéro de compte +-- ============================================ +CREATE OR REPLACE FUNCTION generate_account_number() +RETURNS VARCHAR(20) AS $$ +DECLARE + new_number VARCHAR(20); + prefix VARCHAR(4) := 'DRG'; +BEGIN + new_number := prefix || LPAD(FLOOR(RANDOM() * 10000000000)::TEXT, 13, '0'); + WHILE EXISTS (SELECT 1 FROM accounts WHERE account_number = new_number) LOOP + new_number := prefix || LPAD(FLOOR(RANDOM() * 10000000000)::TEXT, 13, '0'); + END LOOP; + RETURN new_number; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- FONCTION: Mise à jour du timestamp +-- ============================================ +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Triggers +CREATE TRIGGER update_users_timestamp + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER update_accounts_timestamp + BEFORE UPDATE ON accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- ============================================ +-- DONNÉES DE TEST +-- ============================================ + +-- Utilisateur de test (mot de passe: "password123") +INSERT INTO users (id, email, password_hash, first_name, last_name, phone, address, date_of_birth) +VALUES ( + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + 'jean.dupont@email.com', + '$2b$12$LJ3m4ys3GZ5aJfrRkOmU0OYm0MqfGBCqGY5nS5B1VZHvMKvSG1IHa', + 'Jean', + 'Dupont', + '+33612345678', + '12 Rue de la Paix, 75001 Paris', + '1990-05-15' +); + +-- Deuxième utilisateur de test +INSERT INTO users (id, email, password_hash, first_name, last_name, phone, address, date_of_birth) +VALUES ( + 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22', + 'marie.martin@email.com', + '$2b$12$LJ3m4ys3GZ5aJfrRkOmU0OYm0MqfGBCqGY5nS5B1VZHvMKvSG1IHa', + 'Marie', + 'Martin', + '+33698765432', + '5 Avenue des Champs-Élysées, 75008 Paris', + '1985-11-20' +); + +-- Comptes bancaires de test +INSERT INTO accounts (user_id, account_number, account_type, balance, interest_rate) +VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DRG0000000001234', 'courant', 5000.00, 0.0000), + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DRG0000000001235', 'livret_a', 15000.00, 0.0300), + ('b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22', 'DRG0000000005678', 'courant', 3200.00, 0.0000), + ('b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22', 'DRG0000000005679', 'assurance_vie', 25000.00, 0.0200); + +-- Bénéficiaire de test +INSERT INTO beneficiaries (user_id, beneficiary_name, bank_name, account_number) +VALUES + ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'Marie Martin', 'DragonBank', 'DRG0000000005678'); + +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO dragonadmin; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO dragonadmin; \ No newline at end of file diff --git a/DragonBank/docker-compose.yml b/DragonBank/docker-compose.yml new file mode 100644 index 0000000..cfbf9f4 --- /dev/null +++ b/DragonBank/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/frontend/Dockerfile b/DragonBank/frontend/Dockerfile new file mode 100644 index 0000000..d7a16e0 --- /dev/null +++ b/DragonBank/frontend/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +LABEL maintainer="DragonBank Team" +LABEL description="DragonBank Frontend Web App" + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN adduser --disabled-password --gecos '' appuser && \ + chown -R appuser:appuser /app +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=15s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:8080/ || exit 1 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/DragonBank/frontend/app.py b/DragonBank/frontend/app.py new file mode 100644 index 0000000..0ee60de --- /dev/null +++ b/DragonBank/frontend/app.py @@ -0,0 +1,357 @@ +""" +DragonBank - Frontend Web Application +====================================== +Interface web pour l'application bancaire DragonBank. +""" + +import os +from functools import wraps + +import requests +from flask import Flask, render_template, request, redirect, url_for, session, flash + +app = Flask(__name__) +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'frontend-secret') +BACKEND_URL = os.environ.get('BACKEND_URL', 'http://backend:5000') + + +# ============================================ +# UTILITAIRES +# ============================================ +def api_request(method, endpoint, data=None, token=None): + """Effectue une requête vers le backend API.""" + url = f"{BACKEND_URL}{endpoint}" + headers = {'Content-Type': 'application/json'} + if token: + headers['Authorization'] = f'Bearer {token}' + + try: + if method == 'GET': + resp = requests.get(url, headers=headers, timeout=10) + elif method == 'POST': + resp = requests.post(url, json=data, headers=headers, timeout=10) + elif method == 'DELETE': + resp = requests.delete(url, headers=headers, timeout=10) + else: + return None, 'Méthode non supportée' + + return resp.json(), resp.status_code + except requests.exceptions.ConnectionError: + return {'error': 'Impossible de contacter le serveur'}, 503 + except Exception as e: + return {'error': str(e)}, 500 + + +def login_required(f): + """Décorateur pour protéger les routes nécessitant une connexion.""" + @wraps(f) + def decorated(*args, **kwargs): + if 'token' not in session: + flash('Veuillez vous connecter', 'warning') + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated + + +# ============================================ +# ROUTES PUBLIQUES +# ============================================ +@app.route('/') +def home(): + """Page d'accueil.""" + if 'token' in session: + return redirect(url_for('dashboard')) + return render_template('login.html') + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + """Page de connexion.""" + if request.method == 'POST': + email = request.form.get('email') + password = request.form.get('password') + + data, status = api_request('POST', '/api/auth/login', { + 'email': email, + 'password': password + }) + + if status == 200: + session['token'] = data['token'] + session['user'] = data['user'] + flash(f'Bienvenue {data["user"]["first_name"]} ! 🐉', 'success') + return redirect(url_for('dashboard')) + else: + flash(data.get('error', 'Erreur de connexion'), 'danger') + + return render_template('login.html') + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + """Page d'inscription.""" + if request.method == 'POST': + form_data = { + 'email': request.form.get('email'), + 'password': request.form.get('password'), + 'first_name': request.form.get('first_name'), + 'last_name': request.form.get('last_name'), + 'phone': request.form.get('phone', ''), + 'address': request.form.get('address', ''), + 'date_of_birth': request.form.get('date_of_birth') or None + } + + # Vérifier confirmation mot de passe + if request.form.get('password') != request.form.get('confirm_password'): + flash('Les mots de passe ne correspondent pas', 'danger') + return render_template('register.html') + + data, status = api_request('POST', '/api/auth/register', form_data) + + if status == 201: + session['token'] = data['token'] + session['user'] = data['user'] + flash('Compte créé avec succès ! Un compte courant a été ouvert automatiquement. 🎉', 'success') + return redirect(url_for('dashboard')) + else: + flash(data.get('error', 'Erreur lors de l\'inscription'), 'danger') + + return render_template('register.html') + + +@app.route('/logout') +def logout(): + """Déconnexion.""" + session.clear() + flash('Vous êtes déconnecté', 'info') + return redirect(url_for('login')) + + +# ============================================ +# ROUTES PROTÉGÉES - DASHBOARD +# ============================================ +@app.route('/dashboard') +@login_required +def dashboard(): + """Tableau de bord principal.""" + token = session['token'] + + accounts_data, _ = api_request('GET', '/api/accounts', token=token) + stats_data, _ = api_request('GET', '/api/stats', token=token) + transactions_data, _ = api_request('GET', '/api/transactions?limit=5', token=token) + + return render_template('dashboard.html', + user=session.get('user', {}), + accounts=accounts_data.get('accounts', []), + stats=stats_data.get('stats', {}), + recent_transactions=transactions_data.get('transactions', []) + ) + + +# ============================================ +# ROUTES - COMPTES +# ============================================ +@app.route('/accounts') +@login_required +def accounts(): + """Liste des comptes bancaires.""" + token = session['token'] + data, status = api_request('GET', '/api/accounts', token=token) + + return render_template('accounts.html', + user=session.get('user', {}), + accounts=data.get('accounts', []) + ) + + +@app.route('/accounts/open', methods=['GET', 'POST']) +@login_required +def open_account(): + """Ouverture d'un nouveau compte.""" + if request.method == 'POST': + token = session['token'] + form_data = { + 'account_type': request.form.get('account_type'), + 'initial_deposit': float(request.form.get('initial_deposit', 0)) + } + + data, status = api_request('POST', '/api/accounts', data=form_data, token=token) + + if status == 201: + flash(f'Compte {form_data["account_type"]} ouvert avec succès ! 🎉', 'success') + return redirect(url_for('accounts')) + else: + flash(data.get('error', 'Erreur lors de l\'ouverture du compte'), 'danger') + + return render_template('open_account.html', user=session.get('user', {})) + + +# ============================================ +# ROUTES - BÉNÉFICIAIRES +# ============================================ +@app.route('/beneficiaries') +@login_required +def beneficiaries(): + """Liste des bénéficiaires.""" + token = session['token'] + data, status = api_request('GET', '/api/beneficiaries', token=token) + + return render_template('beneficiaries.html', + user=session.get('user', {}), + beneficiaries=data.get('beneficiaries', []) + ) + + +@app.route('/beneficiaries/add', methods=['GET', 'POST']) +@login_required +def add_beneficiary(): + """Ajout d'un bénéficiaire.""" + if request.method == 'POST': + token = session['token'] + form_data = { + 'beneficiary_name': request.form.get('beneficiary_name'), + 'account_number': request.form.get('account_number'), + 'bank_name': request.form.get('bank_name', 'DragonBank'), + 'iban': request.form.get('iban', ''), + 'bic': request.form.get('bic', '') + } + + data, status = api_request('POST', '/api/beneficiaries', data=form_data, token=token) + + if status == 201: + flash('Bénéficiaire ajouté avec succès !', 'success') + return redirect(url_for('beneficiaries')) + else: + flash(data.get('error', 'Erreur'), 'danger') + + return render_template('add_beneficiary.html', user=session.get('user', {})) + + +@app.route('/beneficiaries/delete/') +@login_required +def delete_beneficiary(beneficiary_id): + """Suppression d'un bénéficiaire.""" + token = session['token'] + data, status = api_request('DELETE', f'/api/beneficiaries/{beneficiary_id}', token=token) + + if status == 200: + flash('Bénéficiaire supprimé', 'success') + else: + flash(data.get('error', 'Erreur'), 'danger') + + return redirect(url_for('beneficiaries')) + + +# ============================================ +# ROUTES - VIREMENTS +# ============================================ +@app.route('/transfer', methods=['GET', 'POST']) +@login_required +def transfer(): + """Page de virement (interne et entre personnes).""" + token = session['token'] + + accounts_data, _ = api_request('GET', '/api/accounts', token=token) + beneficiaries_data, _ = api_request('GET', '/api/beneficiaries', token=token) + + if request.method == 'POST': + transfer_type = request.form.get('transfer_type') + amount = float(request.form.get('amount', 0)) + from_account_id = request.form.get('from_account_id') + description = request.form.get('description', '') + + if transfer_type == 'internal': + to_account_id = request.form.get('to_account_id') + data, status = api_request('POST', '/api/transfers/internal', { + 'from_account_id': from_account_id, + 'to_account_id': to_account_id, + 'amount': amount, + 'description': description + }, token=token) + elif transfer_type == 'person': + beneficiary_id = request.form.get('beneficiary_id') + data, status = api_request('POST', '/api/transfers/person', { + 'from_account_id': from_account_id, + 'beneficiary_id': beneficiary_id, + 'amount': amount, + 'description': description + }, token=token) + else: + flash('Type de virement invalide', 'danger') + return redirect(url_for('transfer')) + + if status == 200: + flash(data.get('message', 'Virement effectué !'), 'success') + return redirect(url_for('dashboard')) + else: + flash(data.get('error', 'Erreur lors du virement'), 'danger') + + return render_template('transfer.html', + user=session.get('user', {}), + accounts=accounts_data.get('accounts', []), + beneficiaries=beneficiaries_data.get('beneficiaries', []) + ) + + +@app.route('/transfer/external', methods=['GET', 'POST']) +@login_required +def transfer_external(): + """Virement externe (depuis/vers autre banque).""" + token = session['token'] + accounts_data, _ = api_request('GET', '/api/accounts', token=token) + + if request.method == 'POST': + form_data = { + 'account_id': request.form.get('account_id'), + 'amount': float(request.form.get('amount', 0)), + 'external_bank_name': request.form.get('external_bank_name'), + 'external_account_number': request.form.get('external_account_number'), + 'direction': request.form.get('direction'), + 'description': request.form.get('description', '') + } + + data, status = api_request('POST', '/api/transfers/external', form_data, token=token) + + if status == 200: + flash(data.get('message', 'Virement externe effectué !'), 'success') + return redirect(url_for('dashboard')) + else: + flash(data.get('error', 'Erreur'), 'danger') + + return render_template('transfer_external.html', + user=session.get('user', {}), + accounts=accounts_data.get('accounts', []) + ) + + +# ============================================ +# ROUTES - HISTORIQUE +# ============================================ +@app.route('/transactions') +@login_required +def transactions(): + """Historique des transactions.""" + token = session['token'] + account_id = request.args.get('account_id', '') + + endpoint = '/api/transactions?limit=100' + if account_id: + endpoint += f'&account_id={account_id}' + + data, status = api_request('GET', endpoint, token=token) + accounts_data, _ = api_request('GET', '/api/accounts', token=token) + + return render_template('transactions.html', + user=session.get('user', {}), + transactions=data.get('transactions', []), + accounts=accounts_data.get('accounts', []), + selected_account=account_id + ) + + +# ============================================ +# DÉMARRAGE +# ============================================ +if __name__ == '__main__': + print("🐉 DragonBank Frontend starting on port 8080...") + app.run(host='0.0.0.0', port=8080, debug=False) \ No newline at end of file diff --git a/DragonBank/frontend/requirements.txt b/DragonBank/frontend/requirements.txt new file mode 100644 index 0000000..411f23b --- /dev/null +++ b/DragonBank/frontend/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.0 +requests==2.31.0 \ No newline at end of file diff --git a/DragonBank/frontend/static/style.css b/DragonBank/frontend/static/style.css new file mode 100644 index 0000000..2d06292 --- /dev/null +++ b/DragonBank/frontend/static/style.css @@ -0,0 +1,565 @@ +/* ============================================ + DragonBank - Styles CSS + ============================================ */ + + :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; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f0f2f5; + color: var(--dragon-text); + min-height: 100vh; +} + +/* ============================================ + NAVBAR + ============================================ */ +.navbar { + background: var(--dragon-gradient) !important; + padding: 0.8rem 1.5rem; + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.3); +} + +.navbar-brand { + font-size: 1.5rem; + font-weight: 800; + letter-spacing: 1px; +} + +.navbar-brand .dragon-icon { + font-size: 1.8rem; + margin-right: 8px; +} + +.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; +} + +.nav-link:hover, .nav-link.active { + color: #fff !important; + background: rgba(255, 255, 255, 0.15); +} + +.nav-link i { + margin-right: 5px; +} + +.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; + font-weight: 600; + font-size: 0.85rem; +} + +/* ============================================ + LOGIN PAGE + ============================================ */ +.login-page { + min-height: 100vh; + background: var(--dragon-gradient); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.login-container { + width: 100%; + max-width: 450px; +} + +.login-header { + text-align: center; + margin-bottom: 2rem; +} + +.login-header h1 { + color: #fff; + font-size: 2.5rem; + font-weight: 800; + margin-bottom: 0.5rem; +} + +.login-header .dragon-logo { + font-size: 4rem; + display: block; + margin-bottom: 1rem; +} + +.login-header p { + color: rgba(255, 255, 255, 0.7); + font-size: 1.1rem; +} + +.login-card { + background: white; + border-radius: 20px; + padding: 2.5rem; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.login-card h2 { + color: var(--dragon-primary); + margin-bottom: 1.5rem; + text-align: center; + font-weight: 700; +} + +/* ============================================ + FORMS + ============================================ */ +.form-group { + margin-bottom: 1.2rem; +} + +.form-label { + font-weight: 600; + color: var(--dragon-text); + margin-bottom: 0.4rem; + font-size: 0.9rem; +} + +.form-control { + border: 2px solid var(--dragon-border); + border-radius: 10px; + padding: 12px 15px; + font-size: 1rem; + transition: var(--transition); +} + +.form-control:focus { + border-color: var(--dragon-accent); + box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.15); +} + +.form-select { + border: 2px solid var(--dragon-border); + border-radius: 10px; + padding: 12px 15px; + font-size: 1rem; +} + +.form-select:focus { + border-color: var(--dragon-accent); + box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.15); +} + +/* ============================================ + BUTTONS + ============================================ */ +.btn-dragon { + background: var(--dragon-accent); + color: white; + border: none; + padding: 12px 30px; + border-radius: 10px; + font-weight: 700; + font-size: 1rem; + transition: var(--transition); + text-transform: uppercase; + letter-spacing: 1px; +} + +.btn-dragon:hover { + background: #d63051; + color: white; + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(233, 69, 96, 0.4); +} + +.btn-dragon-outline { + background: transparent; + color: var(--dragon-accent); + border: 2px solid var(--dragon-accent); + padding: 10px 25px; + border-radius: 10px; + font-weight: 600; + transition: var(--transition); +} + +.btn-dragon-outline:hover { + background: var(--dragon-accent); + color: white; +} + +.btn-gold { + background: var(--dragon-gold); + color: var(--dragon-dark); + border: none; + padding: 12px 30px; + border-radius: 10px; + font-weight: 700; + transition: var(--transition); +} + +.btn-gold:hover { + background: #e09500; + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(245, 166, 35, 0.4); +} + +.btn-success-custom { + background: var(--dragon-success); + color: white; + border: none; + padding: 12px 30px; + border-radius: 10px; + font-weight: 700; + transition: var(--transition); +} + +.btn-success-custom:hover { + background: #00a383; + color: white; + transform: translateY(-2px); +} + +/* ============================================ + DASHBOARD + ============================================ */ +.main-content { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.page-header { + margin-bottom: 2rem; +} + +.page-header h1 { + color: var(--dragon-primary); + font-weight: 800; + font-size: 2rem; +} + +.page-header p { + color: var(--dragon-text-light); + font-size: 1.1rem; +} + +/* ============================================ + STAT CARDS + ============================================ */ +.stat-card { + background: white; + border-radius: 16px; + padding: 1.5rem; + box-shadow: var(--dragon-shadow); + transition: var(--transition); + border: none; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.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 .stat-icon { + width: 60px; + height: 60px; + border-radius: 15px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.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 .stat-value { + font-size: 1.8rem; + font-weight: 800; + color: var(--dragon-primary); + line-height: 1; +} + +.stat-card .stat-label { + font-size: 0.85rem; + color: var(--dragon-text-light); + margin-top: 0.3rem; + 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; +} + +.account-card:hover { + transform: translateY(-3px); + box-shadow: var(--dragon-shadow-hover); +} + +.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; + font-size: 0.75rem; + font-weight: 700; + 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-balance { + font-size: 2rem; + font-weight: 800; + color: var(--dragon-primary); +} + +.account-number { + font-family: 'Courier New', monospace; + color: var(--dragon-text-light); + font-size: 0.9rem; + letter-spacing: 2px; +} + +/* ============================================ + TABLES + ============================================ */ +.custom-table { + background: white; + border-radius: 16px; + overflow: hidden; + box-shadow: var(--dragon-shadow); +} + +.custom-table .table { + margin-bottom: 0; +} + +.custom-table .table thead { + background: var(--dragon-primary); + color: white; +} + +.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 td { + padding: 1rem; + vertical-align: middle; + border-bottom: 1px solid var(--dragon-border); +} + +.custom-table .table tbody tr:hover { + background-color: rgba(233, 69, 96, 0.03); +} + +.transaction-amount.credit { + color: var(--dragon-success); + font-weight: 700; +} + +.transaction-amount.debit { + color: var(--dragon-danger); + font-weight: 700; +} + +/* ============================================ + TRANSACTION STATUS BADGES + ============================================ */ +.status-badge { + padding: 4px 12px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 700; +} + +.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); } + +/* ============================================ + 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; +} + +.alert-success { + background: rgba(0, 184, 148, 0.1); + color: #00a383; + border-left: 4px solid var(--dragon-success); +} + +.alert-danger { + background: rgba(214, 48, 49, 0.1); + color: #c0392b; + border-left: 4px solid var(--dragon-danger); +} + +.alert-warning { + background: rgba(253, 203, 110, 0.15); + color: #e17055; + border-left: 4px solid var(--dragon-gold); +} + +.alert-info { + background: rgba(9, 132, 227, 0.1); + color: var(--dragon-info); + border-left: 4px solid var(--dragon-info); +} + +/* ============================================ + 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; + } +} + +/* ============================================ + 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); + text-align: center; + padding: 1.5rem; + margin-top: 3rem; + font-size: 0.85rem; +} + +.footer a { + color: var(--dragon-gold); + text-decoration: none; +} \ No newline at end of file diff --git a/DragonBank/frontend/templates/accounts.html b/DragonBank/frontend/templates/accounts.html new file mode 100644 index 0000000..9372b1d --- /dev/null +++ b/DragonBank/frontend/templates/accounts.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}Mes Comptes{% endblock %} + +{% block content %} +
+ + +
+ {% for account in accounts %} +
+ +
+ {% else %} +
+
+ +

Aucun compte bancaire

+

Ouvrez votre premier compte pour commencer

+ + Ouvrir un compte + +
+
+ {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/DragonBank/frontend/templates/add_beneficiary.html b/DragonBank/frontend/templates/add_beneficiary.html new file mode 100644 index 0000000..9338400 --- /dev/null +++ b/DragonBank/frontend/templates/add_beneficiary.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}Ajouter un Bénéficiaire{% endblock %} + +{% block content %} +
+ + +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + + + Pour DragonBank, le numéro commence par DRG + +
+ +
+ + +
+ +
+ + +
+ + + + + Retour + +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/DragonBank/frontend/templates/base.html b/DragonBank/frontend/templates/base.html new file mode 100644 index 0000000..8b4bf48 --- /dev/null +++ b/DragonBank/frontend/templates/base.html @@ -0,0 +1,123 @@ + + + + + + 🐉 DragonBank - {% block title %}Accueil{% endblock %} + + + + + + + + + + {% if session.get('token') %} + + + {% endif %} + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + + {% block content %}{% endblock %} + + {% if session.get('token') %} +
+

🐉 DragonBank © 2024 — Votre banque de confiance | + Sécurisé par Dragon Shield™

+
+ {% endif %} + + + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/DragonBank/frontend/templates/dashboard.html b/DragonBank/frontend/templates/dashboard.html new file mode 100644 index 0000000..f1a8dc6 --- /dev/null +++ b/DragonBank/frontend/templates/dashboard.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% block title %}Tableau de bord{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+
{{ "%.2f"|format(stats.get('total_balance', 0)) }} €
+
Solde total
+
+
+
+
+
+
{{ stats.get('total_accounts', 0) }}
+
Comptes actifs
+
+
+
+
+
+
{{ stats.get('monthly_transactions', 0) }}
+
Transactions ce mois
+
+
+
+
+
+
{{ stats.get('total_beneficiaries', 0) }}
+
Bénéficiaires
+
+
+
+ +
+ +
+
+
+

+ Mes Comptes +

+ + Ouvrir un compte + +
+ + {% if accounts %} + {% for account in accounts %} + + {% endfor %} + {% else %} +
+ +

Aucun compte trouvé

+
+ {% endif %} +
+
+ + +
+ + + + +
+
+

+ Dernières opérations +

+ + Voir tout + +
+ + {% if recent_transactions %} + {% for tx in recent_transactions %} +
+
+
+ {{ tx.description[:40] }}{% if tx.description|length > 40 %}...{% endif %} +
+ + {{ tx.created_at[:10] if tx.created_at else '' }} + +
+
+ + {{ "%.2f"|format(tx.amount) }} € + +
+
+ {% endfor %} + {% else %} +

Aucune transaction récente

+ {% endif %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/DragonBank/frontend/templates/login.html b/DragonBank/frontend/templates/login.html new file mode 100644 index 0000000..f5de858 --- /dev/null +++ b/DragonBank/frontend/templates/login.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}Connexion{% endblock %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/DragonBank/frontend/templates/register.html b/DragonBank/frontend/templates/register.html new file mode 100644 index 0000000..3e07c14 --- /dev/null +++ b/DragonBank/frontend/templates/register.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}Inscription{% endblock %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/DragonBank/frontend/templates/transfer_external.html b/DragonBank/frontend/templates/transfer_external.html new file mode 100644 index 0000000..d333fc5 --- /dev/null +++ b/DragonBank/frontend/templates/transfer_external.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}Virement Externe{% endblock %} + +{% block content %} +
+ + +
+
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + Note: Les virements externes sont simulés dans cette démo. + En production, ils seraient traités via le réseau bancaire SEPA. +
+ + + + + Annuler + +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/DragonBank/interests/Dockerfile b/DragonBank/interests/Dockerfile new file mode 100644 index 0000000..dbc0070 --- /dev/null +++ b/DragonBank/interests/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +LABEL maintainer="DragonBank Team" +LABEL description="DragonBank Interest Calculator Service" + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +RUN adduser --disabled-password --gecos '' appuser && \ + chown -R appuser:appuser /app +USER appuser + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD python -c "import psycopg2; psycopg2.connect('${DATABASE_URL}')" || exit 1 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/DragonBank/interests/app.py b/DragonBank/interests/app.py new file mode 100644 index 0000000..a61713a --- /dev/null +++ b/DragonBank/interests/app.py @@ -0,0 +1,191 @@ +""" +DragonBank - Service de Calcul des Intérêts +============================================ +Service indépendant qui calcule et applique les intérêts +sur les comptes épargne (Livret A, Assurance Vie). + +Bonus: +3% journalier sur les comptes épargne. +""" + +import os +import time +import decimal +from datetime import datetime, timezone + +import psycopg2 +import psycopg2.extras +import schedule + +# ============================================ +# CONFIGURATION +# ============================================ +DATABASE_URL = os.environ.get( + 'DATABASE_URL', + 'postgresql://dragonadmin:dragonpass@db:5432/dragonbank' +) +INTEREST_RATE_LIVRET_A = float(os.environ.get('INTEREST_RATE_LIVRET_A', 0.03)) +INTEREST_RATE_ASSURANCE_VIE = float(os.environ.get('INTEREST_RATE_ASSURANCE_VIE', 0.02)) +INTERVAL_SECONDS = int(os.environ.get('INTERVAL_SECONDS', 86400)) # 24h par défaut + + +def get_db_connection(): + """Crée une connexion à la base de données.""" + conn = psycopg2.connect( + DATABASE_URL, + cursor_factory=psycopg2.extras.RealDictCursor + ) + return conn + + +def calculate_interests(): + """ + Calcule et applique les intérêts sur tous les comptes épargne actifs. + + - Livret A: INTEREST_RATE_LIVRET_A (par défaut 3%) + - Assurance Vie: INTEREST_RATE_ASSURANCE_VIE (par défaut 2%) + """ + print(f"\n{'='*60}") + print(f"🐉 CALCUL DES INTÉRÊTS - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}") + print(f"{'='*60}") + + conn = get_db_connection() + try: + cur = conn.cursor() + + # Récupérer tous les comptes épargne actifs + cur.execute(''' + SELECT id, user_id, account_number, account_type, balance, interest_rate + FROM accounts + WHERE status = 'active' + AND account_type IN ('livret_a', 'assurance_vie') + AND balance > 0 + ''') + + accounts = cur.fetchall() + total_interest_paid = decimal.Decimal('0.00') + accounts_processed = 0 + + for account in accounts: + account_id = str(account['id']) + balance = decimal.Decimal(str(account['balance'])) + account_type = account['account_type'] + + # Déterminer le taux d'intérêt + if account_type == 'livret_a': + rate = decimal.Decimal(str(INTEREST_RATE_LIVRET_A)) + elif account_type == 'assurance_vie': + rate = decimal.Decimal(str(INTEREST_RATE_ASSURANCE_VIE)) + else: + continue + + # Calculer les intérêts + interest_amount = (balance * rate).quantize(decimal.Decimal('0.01')) + + if interest_amount <= 0: + continue + + new_balance = balance + interest_amount + + # Appliquer les intérêts + cur.execute( + 'UPDATE accounts SET balance = %s, updated_at = NOW() WHERE id = %s', + (float(new_balance), account_id) + ) + + # Enregistrer dans l'historique des intérêts + cur.execute(''' + INSERT INTO interest_history + (account_id, amount, rate, balance_before, balance_after) + VALUES (%s, %s, %s, %s, %s) + ''', ( + account_id, + float(interest_amount), + float(rate), + float(balance), + float(new_balance) + )) + + # Créer une transaction pour traçabilité + cur.execute(''' + INSERT INTO transactions + (to_account_id, transaction_type, amount, description, status, executed_at) + VALUES (%s, 'interets', %s, %s, 'completed', NOW()) + ''', ( + account_id, + float(interest_amount), + f"Intérêts {account_type.replace('_', ' ').title()} ({float(rate)*100:.1f}%)" + )) + + total_interest_paid += interest_amount + accounts_processed += 1 + + print(f" ✅ {account['account_number']} ({account_type}): " + f"{float(balance):.2f}€ → {float(new_balance):.2f}€ " + f"(+{float(interest_amount):.2f}€ à {float(rate)*100:.1f}%)") + + conn.commit() + + print(f"\n📊 Résumé:") + print(f" Comptes traités: {accounts_processed}") + print(f" Total intérêts versés: {float(total_interest_paid):.2f}€") + print(f"{'='*60}\n") + + except Exception as e: + conn.rollback() + print(f" ❌ ERREUR lors du calcul des intérêts: {e}") + finally: + conn.close() + + +def main(): + """Point d'entrée principal du service d'intérêts.""" + print("🐉" + "="*58) + print(" DragonBank - Service de Calcul des Intérêts") + print("="*60) + print(f" Taux Livret A: {INTEREST_RATE_LIVRET_A*100:.1f}% / cycle") + print(f" Taux Assurance Vie: {INTEREST_RATE_ASSURANCE_VIE*100:.1f}% / cycle") + print(f" Intervalle: {INTERVAL_SECONDS} secondes") + print(f" Database: {DATABASE_URL.split('@')[1] if '@' in DATABASE_URL else 'configured'}") + print("="*60) + + # Attendre que la DB soit prête + print("\n⏳ Attente de la connexion à la base de données...") + max_retries = 30 + for i in range(max_retries): + try: + conn = get_db_connection() + conn.close() + print("✅ Connexion à la base de données établie !") + break + except Exception as e: + if i < max_retries - 1: + print(f" Tentative {i+1}/{max_retries} échouée: {e}") + time.sleep(2) + else: + print(f"❌ Impossible de se connecter à la DB après {max_retries} tentatives") + return + + # Premier calcul immédiat + print("\n🚀 Premier calcul des intérêts...") + calculate_interests() + + # Planifier les calculs suivants + # Pour la démo, on peut réduire l'intervalle + if INTERVAL_SECONDS < 3600: + # Si intervalle court (démo), utiliser un simple sleep + print(f"\n⏰ Mode démo: calcul toutes les {INTERVAL_SECONDS} secondes") + while True: + time.sleep(INTERVAL_SECONDS) + calculate_interests() + else: + # En production, calcul journalier + schedule.every(INTERVAL_SECONDS).seconds.do(calculate_interests) + print(f"\n⏰ Prochain calcul dans {INTERVAL_SECONDS} secondes") + + while True: + schedule.run_pending() + time.sleep(60) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/DragonBank/interests/requirements.txt b/DragonBank/interests/requirements.txt new file mode 100644 index 0000000..6651494 --- /dev/null +++ b/DragonBank/interests/requirements.txt @@ -0,0 +1,2 @@ +psycopg2-binary==2.9.9 +schedule==1.2.1 \ No newline at end of file diff --git a/DragonBank/secrets/db_password.txt b/DragonBank/secrets/db_password.txt new file mode 100644 index 0000000..92ee63a --- /dev/null +++ b/DragonBank/secrets/db_password.txt @@ -0,0 +1 @@ +dragonpass \ No newline at end of file