This commit is contained in:
2026-03-27 17:52:41 +01:00
parent 3cf8233054
commit 8320738acb
32 changed files with 5113 additions and 1385 deletions
+33
View File
@@ -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`).
+4 -2
View File
@@ -6,6 +6,8 @@ LABEL description="DragonBank Backend API"
# Variables d'environnement # Variables d'environnement
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# Permet a Python de trouver les modules locaux (config, database, auth...)
ENV PYTHONPATH=/app
# Répertoire de travail # Répertoire de travail
WORKDIR /app WORKDIR /app
@@ -24,8 +26,8 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \ RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt pip install --no-cache-dir -r requirements.txt
# Copie du code source # Copie de tout le code source (app.py + modules + dossier models/)
COPY app.py . COPY . .
# Création d'un utilisateur non-root (sécurité) # Création d'un utilisateur non-root (sécurité)
RUN adduser --disabled-password --gecos '' appuser && \ RUN adduser --disabled-password --gecos '' appuser && \
+357 -856
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -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
+113
View File
@@ -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
+142
View File
@@ -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)
)
+105
View File
@@ -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
+198
View File
@@ -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
+264
View File
@@ -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
+113
View File
@@ -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
+447
View File
@@ -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())
+243
View File
@@ -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
+1 -1
View File
@@ -3,4 +3,4 @@ Flask-CORS==4.0.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
bcrypt==4.1.2 bcrypt==4.1.2
PyJWT==2.8.0 PyJWT==2.8.0
gunicorn==21.2.0 gunicorn==21.2.0
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
+181
View File
@@ -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")
+16 -6
View File
@@ -7,7 +7,9 @@ services:
environment: environment:
POSTGRES_DB: dragonbank POSTGRES_DB: dragonbank
POSTGRES_USER: dragonadmin POSTGRES_USER: dragonadmin
POSTGRES_PASSWORD: dragonpass POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
@@ -29,9 +31,11 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: dragonbank-backend container_name: dragonbank-backend
environment: environment:
DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank POSTGRES_PASSWORD_FILE: /run/secrets/db_password
SECRET_KEY: dragonbank-super-secret-key-2024 SECRET_KEY: dragonbank-super-secret-key-2024
FLASK_ENV: production FLASK_ENV: production
secrets:
- db_password
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -77,17 +81,19 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: dragonbank-interests container_name: dragonbank-interests
environment: environment:
DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank POSTGRES_PASSWORD_FILE: /run/secrets/db_password
INTEREST_RATE_LIVRET_A: 0.03 INTEREST_RATE_LIVRET_A: 0.03
INTEREST_RATE_ASSURANCE_VIE: 0.02 INTEREST_RATE_ASSURANCE_VIE: 0.02
INTERVAL_SECONDS: 86400 INTERVAL_SECONDS: 60
secrets:
- db_password
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: networks:
- dragonbank-backend-net - dragonbank-backend-net
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -102,4 +108,8 @@ networks:
dragonbank-backend-net: dragonbank-backend-net:
driver: bridge driver: bridge
dragonbank-frontend-net: dragonbank-frontend-net:
driver: bridge driver: bridge
secrets:
db_password:
file: ./secrets/db_password.txt
+148
View File
@@ -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 # 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 # DÉMARRAGE
# ============================================ # ============================================
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -63,14 +63,20 @@
<i class="bi bi-clock-history"></i> Historique <i class="bi bi-clock-history"></i> Historique
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'simulator' %}active{% endif %}"
href="{{ url_for('simulator') }}">
<i class="bi bi-graph-up-arrow"></i> Simulateur
</a>
</li>
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item me-3 d-flex align-items-center"> <li class="nav-item me-3 d-flex align-items-center">
<span class="user-badge"> <a href="{{ url_for('profile') }}" class="user-badge text-decoration-none {% if request.endpoint == 'profile' %}text-warning{% endif %}">
<i class="bi bi-person-circle"></i> <i class="bi bi-person-circle"></i>
{{ session.get('user', {}).get('first_name', '') }} {{ session.get('user', {}).get('first_name', '') }}
{{ session.get('user', {}).get('last_name', '') }} {{ session.get('user', {}).get('last_name', '') }}
</span> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-danger" href="{{ url_for('logout') }}"> <a class="nav-link text-danger" href="{{ url_for('logout') }}">
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Mes Bénéficiaires{% endblock %}
{% block content %}
<div class="main-content">
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<h1><i class="bi bi-people"></i> Mes Bénéficiaires</h1>
<p>Gérez vos contacts de virement</p>
</div>
<a href="{{ url_for('add_beneficiary') }}" class="btn btn-dragon">
<i class="bi bi-person-plus"></i> Ajouter un bénéficiaire
</a>
</div>
<div class="row g-4">
{% for b in beneficiaries %}
<div class="col-lg-6 fade-in delay-{{ loop.index }}">
<div class="section-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="mb-1">
<i class="bi bi-person-circle"></i> {{ b.beneficiary_name }}
</h5>
<p class="text-muted mb-1">
<i class="bi bi-bank"></i> {{ b.bank_name }}
</p>
<p class="mb-1">
<i class="bi bi-hash"></i>
<code>{{ b.account_number }}</code>
</p>
{% if b.iban %}
<p class="text-muted mb-1">
<i class="bi bi-upc"></i> {{ b.iban }}
</p>
{% endif %}
<small class="text-muted">
<i class="bi bi-calendar"></i>
Ajouté le {{ b.created_at[:10] if b.created_at else 'N/A' }}
</small>
</div>
<div class="d-flex flex-column gap-2">
<span class="status-badge completed">
<i class="bi bi-check-circle"></i> {{ b.status }}
</span>
<a href="{{ url_for('delete_beneficiary', beneficiary_id=b.id) }}"
class="btn btn-sm btn-danger"
onclick="return confirm('Supprimer ce bénéficiaire ?')">
<i class="bi bi-trash"></i> Supprimer
</a>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('transfer') }}" class="btn btn-sm btn-dragon">
<i class="bi bi-send"></i> Faire un virement
</a>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="section-card text-center py-5">
<i class="bi bi-people" style="font-size: 4rem; color: var(--dragon-text-light);"></i>
<h3 class="mt-3">Aucun bénéficiaire enregistré</h3>
<p class="text-muted">Ajoutez un bénéficiaire pour effectuer des virements</p>
<a href="{{ url_for('add_beneficiary') }}" class="btn btn-dragon mt-2">
<i class="bi bi-person-plus"></i> Ajouter un bénéficiaire
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Ouvrir un Compte{% endblock %}
{% block content %}
<div class="main-content">
<div class="page-header">
<h1><i class="bi bi-plus-circle"></i> Ouvrir un Compte</h1>
<p>Choisissez le type de compte à ouvrir</p>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="section-card fade-in">
<form method="POST" action="{{ url_for('open_account') }}">
<!-- Type de compte -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-wallet2"></i> Type de compte *
</label>
<select name="account_type" class="form-select" required>
<option value="">-- Choisissez un type --</option>
<option value="courant">
Compte Courant — taux 0 %
</option>
<option value="livret_a">
Livret A — taux 3 % / cycle
</option>
<option value="assurance_vie">
Assurance Vie — taux 2 % / cycle
</option>
</select>
<small class="text-muted">
Un seul Livret A et une seule Assurance Vie par client.
</small>
</div>
<!-- Dépôt initial -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-currency-euro"></i> Dépôt initial (optionnel)
</label>
<input type="number" name="initial_deposit" class="form-control"
min="0" step="0.01" placeholder="0.00" value="0">
<small class="text-muted">
Laissez 0 pour ouvrir le compte sans dépôt initial.
</small>
</div>
<button type="submit" class="btn btn-dragon w-100 mt-3">
<i class="bi bi-check-circle"></i> Ouvrir le compte
</button>
<a href="{{ url_for('accounts') }}" class="btn btn-dragon-outline w-100 mt-2">
<i class="bi bi-arrow-left"></i> Retour à mes comptes
</a>
</form>
</div>
<!-- Récapitulatif des types de comptes -->
<div class="row g-3 mt-2">
<div class="col-12">
<div class="section-card courant" style="border-left: 4px solid var(--dragon-primary);">
<h6><i class="bi bi-credit-card"></i> Compte Courant</h6>
<p class="text-muted mb-0 small">
Compte de tous les jours pour vos dépenses et virements. Pas d'intérêts.
Plusieurs comptes autorisés.
</p>
</div>
</div>
<div class="col-12">
<div class="section-card" style="border-left: 4px solid #28a745;">
<h6><i class="bi bi-piggy-bank"></i> Livret A</h6>
<p class="text-muted mb-0 small">
Épargne réglementée à taux fixe de 3 % par cycle. Un seul Livret A autorisé.
</p>
</div>
</div>
<div class="col-12">
<div class="section-card" style="border-left: 4px solid #ffc107;">
<h6><i class="bi bi-shield-check"></i> Assurance Vie</h6>
<p class="text-muted mb-0 small">
Placement long terme à 2 % par cycle sur les fonds euros.
Une seule Assurance Vie autorisée.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+146
View File
@@ -0,0 +1,146 @@
{% extends 'base.html' %}
{% block title %}Mon Profil{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row mb-4">
<div class="col-12 text-center text-white">
<h2 class="dragon-title"><i class="bi bi-shield-lock"></i> Mon Profil & Sécurité</h2>
<p>Gérez vos informations personnelles, bancaires et de connexion</p>
</div>
</div>
<div class="row justify-content-center">
<!-- COLONNE GAUCHE : INFOS PERSONNELLES & BANCAIRES -->
<div class="col-md-6 mb-4">
<!-- INFORMATIONS PERSONNELLES -->
<div class="card bg-dark text-white dragon-card mb-4">
<div class="card-header bg-transparent border-bottom border-secondary">
<h5 class="mb-0"><i class="bi bi-person-vcard"></i> Informations Personnelles</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('profile') }}">
<input type="hidden" name="action" value="update_profile">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label text-muted">Prénom</label>
<input type="text" class="form-control bg-secondary text-white border-0" value="{{ user_profile.get('first_name', '') }}" disabled>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted">Nom</label>
<input type="text" class="form-control bg-secondary text-white border-0" value="{{ user_profile.get('last_name', '') }}" disabled>
</div>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Numéro de téléphone</label>
<input type="text" class="form-control" id="phone" name="phone" value="{{ user_profile.get('phone', '') or '' }}">
</div>
<div class="mb-3">
<label for="address" class="form-label">Adresse postale</label>
<input type="text" class="form-control" id="address" name="address" value="{{ user_profile.get('address', '') or '' }}">
</div>
<button type="submit" class="btn btn-warning w-100 dragon-btn">Mettre à jour mes informations</button>
</form>
</div>
</div>
<!-- INFORMATIONS BANCAIRES -->
<div class="card bg-dark text-white dragon-card">
<div class="card-header bg-transparent border-bottom border-secondary">
<h5 class="mb-0"><i class="bi bi-bank"></i> Mes Comptes Bancaires</h5>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush bg-transparent">
{% for account in accounts %}
<li class="list-group-item bg-transparent text-white border-secondary">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>{{ account.account_type | capitalize | replace('_', ' ') }}</strong>
<small class="d-block text-muted font-monospace">{{ account.account_number }}</small>
</div>
<span class="badge bg-success rounded-pill px-3 py-2 fs-6">{{ "%.2f"|format(account.balance) }} €</span>
</div>
</li>
{% else %}
<li class="list-group-item bg-transparent text-white border-0 text-muted text-center py-4">
<i class="bi bi-wallet2 fs-2 d-block mb-2"></i>
Aucun compte bancaire ouvert
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<!-- COLONNE DROITE : EMAIL ET MOT DE PASSE -->
<div class="col-md-6 mb-4">
<!-- CHANGEMENT EMAIL -->
<div class="card bg-dark text-white dragon-card mb-4">
<div class="card-header bg-transparent border-bottom border-secondary">
<h5 class="mb-0"><i class="bi bi-envelope"></i> Changer d'adresse e-mail</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('profile') }}">
<input type="hidden" name="action" value="update_email">
<div class="mb-3">
<label class="form-label text-muted">Adresse e-mail actuelle</label>
<input type="text" class="form-control bg-secondary text-white border-0" value="{{ user_profile.get('email', '') }}" disabled>
</div>
<div class="mb-3">
<label for="new_email" class="form-label">Nouvelle adresse e-mail</label>
<input type="email" class="form-control" id="new_email" name="new_email" required>
</div>
<div class="mb-3">
<label for="password_for_email" class="form-label">Mot de passe actuel (requis)</label>
<input type="password" class="form-control" id="password_for_email" name="password" required>
</div>
<button type="submit" class="btn btn-warning w-100 dragon-btn">Modifier mon e-mail</button>
</form>
</div>
</div>
<!-- CHANGEMENT MOT DE PASSE -->
<div class="card bg-dark text-white dragon-card">
<div class="card-header bg-transparent border-bottom border-secondary">
<h5 class="mb-0"><i class="bi bi-key"></i> Changer de mot de passe</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('profile') }}">
<input type="hidden" name="action" value="update_password">
<div class="mb-3">
<label for="current_password" class="form-label">Mot de passe actuel</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Nouveau mot de passe</label>
<input type="password" class="form-control" id="new_password" name="new_password" required>
<div class="form-text text-muted">Minimum 8 caractères, une majuscule et un chiffre.</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirmez le mot de passe</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-warning w-100 dragon-btn">Mettre à jour le mot de passe</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,321 @@
{% extends "base.html" %}
{% block title %}Simulateur d'Épargne{% endblock %}
{% block content %}
<div class="main-content">
<div class="page-header">
<h1><i class="bi bi-graph-up-arrow"></i> Simulateur d'Épargne</h1>
<p>Projetez la croissance de votre épargne avec les intérêts composés</p>
</div>
<div class="row g-4">
<!-- Formulaire de simulation -->
<div class="col-lg-4">
<div class="section-card">
<div class="section-title">Paramètres</div>
<form method="POST" action="{{ url_for('simulator') }}" id="simForm">
{% if comptes_epargne %}
<div class="form-group">
<label class="form-label">
<i class="bi bi-piggy-bank"></i> Pré-remplir depuis un compte
</label>
<select id="presetCompte" class="form-select">
<option value="">— Saisie manuelle —</option>
{% for c in comptes_epargne %}
<option value="{{ c.balance }}" data-taux="{{ c.interest_rate * 100 }}">
{% if c.account_type == 'livret_a' %}Livret A
{% elif c.account_type == 'assurance_vie' %}Assurance Vie
{% else %}{{ c.account_type }}{% endif %}
— {{ "%.2f"|format(c.balance) }} €
({{ "%.1f"|format(c.interest_rate * 100) }} %)
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-group">
<label class="form-label">
<i class="bi bi-currency-euro"></i> Capital initial (€)
</label>
<input type="number" name="capital_initial" id="capitalInitial"
class="form-control" min="0" step="100" value="1000" required>
</div>
<div class="form-group">
<label class="form-label">
<i class="bi bi-percent"></i> Taux annuel (%)
</label>
<input type="number" name="taux_annuel" id="tauxAnnuel"
class="form-control" min="0" max="100" step="0.1" value="3.0" required>
</div>
<div class="form-group">
<label class="form-label">
<i class="bi bi-calendar-range"></i> Durée (années)
</label>
<input type="number" name="duree_annees" id="dureeAnnees"
class="form-control" min="1" max="40" step="1" value="10" required>
<small class="form-text">Entre 1 et 40 ans</small>
</div>
<div class="form-group">
<label class="form-label">
<i class="bi bi-plus-circle"></i> Versement mensuel (€)
</label>
<input type="number" name="versement_mensuel" id="versementMensuel"
class="form-control" min="0" step="50" value="0">
<small class="form-text">Optionnel — laissez 0 si aucun</small>
</div>
<button type="submit" class="btn btn-dragon w-100 mt-2">
<i class="bi bi-calculator"></i> Calculer
</button>
</form>
</div>
</div>
<!-- Résultats -->
<div class="col-lg-8">
{% if resultat %}
<!-- Métriques clés -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="stat-card success fade-in">
<div class="stat-icon">
<i class="bi bi-trophy"></i>
</div>
<div class="stat-value">
{{ "%.2f"|format(resultat.capital_final) }} €
</div>
<div class="stat-label">Capital final</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card info fade-in delay-1">
<div class="stat-icon">
<i class="bi bi-graph-up"></i>
</div>
<div class="stat-value">
{{ "%.2f"|format(resultat.total_interets) }} €
</div>
<div class="stat-label">Intérêts générés</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card warning fade-in delay-2">
<div class="stat-icon">
<i class="bi bi-percent"></i>
</div>
<div class="stat-value">
+{{ "%.1f"|format(resultat.gain_pourcentage) }} %
</div>
<div class="stat-label">Gain sur versements</div>
</div>
</div>
</div>
<!-- Graphique -->
<div class="section-card fade-in delay-3">
<div class="section-title">
<i class="bi bi-bar-chart-line"></i> Évolution sur {{ resultat.duree_annees }} an(s)
</div>
<canvas id="simulatorChart" height="280"></canvas>
</div>
<!-- Tableau année par année -->
<div class="section-card fade-in delay-4">
<div class="section-title">
<i class="bi bi-table"></i> Détail annuel
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Année</th>
<th class="text-end">Total versé</th>
<th class="text-end">Intérêts</th>
<th class="text-end">Capital total</th>
</tr>
</thead>
<tbody>
{% for point in resultat.courbe %}
<tr>
<td>
<span class="badge bg-secondary">An {{ point.annee }}</span>
</td>
<td class="text-end text-muted" style="font-family: var(--font-mono);">
{{ "%.2f"|format(point.total_verse) }} €
</td>
<td class="text-end" style="font-family: var(--font-mono); color: var(--c-green); font-weight: 500;">
+{{ "%.2f"|format(point.interets) }} €
</td>
<td class="text-end fw-bold" style="font-family: var(--font-mono);">
{{ "%.2f"|format(point.solde) }} €
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<!-- État initial — pas encore simulé -->
<div class="section-card text-center py-5 fade-in">
<i class="bi bi-graph-up-arrow"
style="font-size: 5rem; color: var(--c-accent); opacity: 0.3;"></i>
<h3 class="mt-4" style="color: var(--c-text-secondary);">
Renseignez les paramètres et cliquez sur <strong>Calculer</strong>
</h3>
<p class="text-muted mt-2">
Le simulateur applique la formule des intérêts composés avec versements mensuels réguliers.
</p>
<div class="row g-3 mt-4 text-start">
<div class="col-md-4">
<div class="section-card" style="border-left: 3px solid var(--c-green);">
<h6 class="fw-bold mb-1">
<i class="bi bi-piggy-bank" style="color: var(--c-green);"></i>
Livret A
</h6>
<p class="text-muted mb-0" style="font-size: 0.8125rem;">
Taux réglementé de 3 % par cycle. Essayez avec 10 ans.
</p>
</div>
</div>
<div class="col-md-4">
<div class="section-card" style="border-left: 3px solid var(--c-amber);">
<h6 class="fw-bold mb-1">
<i class="bi bi-shield-check" style="color: var(--c-amber);"></i>
Assurance Vie
</h6>
<p class="text-muted mb-0" style="font-size: 0.8125rem;">
Fonds euros à 2 % par cycle. Idéal sur le long terme.
</p>
</div>
</div>
<div class="col-md-4">
<div class="section-card" style="border-left: 3px solid var(--c-accent);">
<h6 class="fw-bold mb-1">
<i class="bi bi-plus-circle" style="color: var(--c-accent);"></i>
Versement mensuel
</h6>
<p class="text-muted mb-0" style="font-size: 0.8125rem;">
Ajoutez un versement régulier pour voir l'effet de l'épargne progressive.
</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{% if resultat %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script>
var courbe = {{ resultat.courbe | tojson }};
var labels = courbe.map(function(p) { return 'An ' + p.annee; });
var soldes = courbe.map(function(p) { return p.solde; });
var verses = courbe.map(function(p) { return p.total_verse; });
var interets = courbe.map(function(p) { return p.interets; });
var ctx = document.getElementById('simulatorChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Total versé',
data: verses,
backgroundColor: 'rgba(59, 130, 246, 0.5)',
borderColor: 'rgba(59, 130, 246, 0.8)',
borderWidth: 1,
borderRadius: 4,
stack: 'stack'
},
{
label: 'Intérêts générés',
data: interets,
backgroundColor: 'rgba(0, 200, 150, 0.6)',
borderColor: 'rgba(0, 200, 150, 0.9)',
borderWidth: 1,
borderRadius: 4,
stack: 'stack'
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: "'DM Sans', sans-serif", size: 12 },
usePointStyle: true,
padding: 20
}
},
tooltip: {
callbacks: {
label: function(ctx) {
return ctx.dataset.label + ' : ' + ctx.parsed.y.toFixed(2) + ' €';
},
afterBody: function(items) {
var annee = items[0].dataIndex;
return 'Capital total : ' + soldes[annee].toFixed(2) + ' €';
}
}
}
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: {
font: { family: "'DM Sans', sans-serif", size: 11 }
}
},
y: {
stacked: true,
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: {
font: { family: "'DM Mono', monospace", size: 11 },
callback: function(val) { return val.toFixed(0) + ' €'; }
}
}
}
}
});
</script>
{% endif %}
<script>
// Pré-remplissage depuis le compte épargne sélectionné
var selectPreset = document.getElementById('presetCompte');
if (selectPreset) {
selectPreset.addEventListener('change', function () {
var option = this.options[this.selectedIndex];
var solde = parseFloat(option.value) || 0;
var taux = parseFloat(option.getAttribute('data-taux')) || 0;
if (solde > 0) {
document.getElementById('capitalInitial').value = solde.toFixed(2);
document.getElementById('tauxAnnuel').value = taux.toFixed(1);
}
});
}
</script>
{% endblock %}
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block title %}Historique des Transactions{% endblock %}
{% block content %}
<div class="main-content">
<div class="page-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div>
<h1><i class="bi bi-clock-history"></i> Historique des Transactions</h1>
<p>Consultez et exportez toutes vos opérations bancaires</p>
</div>
<a href="{{ url_for('export_transactions', account_id=selected_account or '') }}"
class="btn btn-dragon-outline">
<i class="bi bi-download"></i> Exporter CSV
</a>
</div>
<!-- Filtres -->
<div class="section-card mb-4">
<form method="GET" action="{{ url_for('transactions') }}" class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label"><i class="bi bi-wallet2"></i> Compte</label>
<select name="account_id" class="form-select">
<option value="">Tous les comptes</option>
{% for account in accounts %}
<option value="{{ account.id }}"
{% if selected_account == account.id|string %}selected{% endif %}>
{{ account.account_number }} —
{% if account.account_type == 'courant' %}Courant
{% elif account.account_type == 'livret_a' %}Livret A
{% elif account.account_type == 'assurance_vie' %}Assurance Vie
{% else %}{{ account.account_type }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label"><i class="bi bi-tag"></i> Type</label>
<select name="type" class="form-select">
<option value="">Tous les types</option>
<option value="virement_interne">Virement interne</option>
<option value="virement_entre_personnes">Virement personne</option>
<option value="virement_externe">Virement externe</option>
<option value="depot">Dépôt</option>
<option value="interets">Intérêts</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label"><i class="bi bi-search"></i> Recherche</label>
<input type="text" id="searchInput" class="form-control"
placeholder="Libellé, numéro de compte...">
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-dragon flex-grow-1">
<i class="bi bi-funnel"></i> Filtrer
</button>
{% if selected_account %}
<a href="{{ url_for('transactions') }}" class="btn btn-dragon-outline" title="Réinitialiser">
<i class="bi bi-x-circle"></i>
</a>
{% endif %}
</div>
</form>
</div>
<!-- Tableau -->
<div class="section-card">
{% if transactions %}
<div class="table-responsive">
<table class="table table-hover" id="transactionsTable">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Libellé</th>
<th>De / Vers</th>
<th class="text-end">Montant</th>
<th>Statut</th>
</tr>
</thead>
<tbody id="transactionsBody">
{% for t in transactions %}
<tr class="transaction-row">
<td><small class="text-muted">{{ t.created_at[:16].replace('T', ' ') if t.created_at else 'N/A' }}</small></td>
<td>
{% if t.transaction_type == 'virement_interne' %}
<span class="badge bg-primary">Interne</span>
{% elif t.transaction_type == 'virement_entre_personnes' %}
<span class="badge bg-info">Personne</span>
{% elif t.transaction_type == 'virement_externe' %}
<span class="badge bg-warning">Externe</span>
{% elif t.transaction_type == 'depot' %}
<span class="badge bg-success">Dépôt</span>
{% elif t.transaction_type == 'retrait' %}
<span class="badge bg-danger">Retrait</span>
{% elif t.transaction_type == 'interets' %}
<span class="badge bg-success">Intérêts</span>
{% else %}
<span class="badge bg-secondary">{{ t.transaction_type }}</span>
{% endif %}
</td>
<td><small>{{ t.description or '—' }}</small></td>
<td>
<small>
{% if t.from_account_number %}<code>{{ t.from_account_number }}</code>{% endif %}
{% if t.from_account_number and t.to_account_number %}<i class="bi bi-arrow-right text-muted"></i>{% endif %}
{% if t.to_account_number %}<code>{{ t.to_account_number }}</code>{% endif %}
{% if t.external_bank_name %}<i class="bi bi-bank text-muted"></i> {{ t.external_bank_name }}{% endif %}
</small>
</td>
<td class="text-end" style="font-family: var(--font-mono); letter-spacing: -0.5px; font-weight: 500;">
{{ "%.2f"|format(t.amount) }} €
</td>
<td>
{% if t.status == 'completed' %}
<span class="status-badge completed"><i class="bi bi-check-circle"></i> Effectué</span>
{% elif t.status == 'pending' %}
<span class="status-badge pending"><i class="bi bi-hourglass"></i> En cours</span>
{% elif t.status == 'failed' %}
<span class="status-badge failed"><i class="bi bi-x-circle"></i> Échoué</span>
{% else %}
<span class="badge bg-secondary">{{ t.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted" id="transactionCount">
<i class="bi bi-info-circle"></i> {{ transactions|length }} transaction(s) affichée(s)
</small>
<a href="{{ url_for('export_transactions', account_id=selected_account or '') }}"
class="btn btn-sm btn-dragon-outline">
<i class="bi bi-download"></i> Exporter en CSV
</a>
</div>
<div id="noResults" class="text-center py-4" style="display: none;">
<i class="bi bi-search" style="font-size: 2.5rem; color: var(--c-text-tertiary);"></i>
<p class="text-muted mt-2">Aucune transaction ne correspond à votre recherche.</p>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-clock-history" style="font-size: 4rem; color: var(--c-text-tertiary);"></i>
<h3 class="mt-3">Aucune transaction</h3>
<p class="text-muted">Votre historique apparaîtra ici après vos premiers virements.</p>
<a href="{{ url_for('transfer') }}" class="btn btn-dragon mt-2">
<i class="bi bi-send"></i> Effectuer un virement
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var inputRecherche = document.getElementById('searchInput');
var corps = document.getElementById('transactionsBody');
var compteur = document.getElementById('transactionCount');
var messageVide = document.getElementById('noResults');
if (inputRecherche && corps) {
inputRecherche.addEventListener('input', function () {
var terme = this.value.toLowerCase().trim();
var lignes = corps.querySelectorAll('.transaction-row');
var nbVisible = 0;
for (var i = 0; i < lignes.length; i++) {
var texte = lignes[i].textContent.toLowerCase();
if (terme === '' || texte.includes(terme)) {
lignes[i].style.display = '';
nbVisible++;
} else {
lignes[i].style.display = 'none';
}
}
if (compteur) {
compteur.innerHTML = '<i class="bi bi-info-circle"></i> ' + nbVisible + ' transaction(s) affichée(s)';
}
if (messageVide) {
messageVide.style.display = (nbVisible === 0 && terme !== '') ? 'block' : 'none';
}
});
}
</script>
{% endblock %}
+178
View File
@@ -0,0 +1,178 @@
{% extends "base.html" %}
{% block title %}Effectuer un Virement{% endblock %}
{% block content %}
<div class="main-content">
<div class="page-header">
<h1><i class="bi bi-arrow-left-right"></i> Effectuer un Virement</h1>
<p>Virement interne entre vos comptes ou vers un bénéficiaire</p>
</div>
<div class="row justify-content-center">
<div class="col-md-7">
<div class="section-card fade-in">
<form method="POST" action="{{ url_for('transfer') }}" id="transferForm">
<!-- Type de virement -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-arrow-repeat"></i> Type de virement *
</label>
<select name="transfer_type" class="form-select" id="transferType" required>
<option value="">-- Choisissez un type --</option>
<option value="internal">Virement interne (entre mes comptes)</option>
<option value="person">Virement vers un bénéficiaire</option>
</select>
</div>
<!-- Compte source (commun aux deux types) -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-wallet2"></i> Compte source *
</label>
<select name="from_account_id" class="form-select" required>
<option value="">-- Choisissez un compte --</option>
{% for account in accounts %}
<option value="{{ account.id }}">
{{ account.account_number }}
{% if account.account_type == 'courant' %}Compte Courant
{% elif account.account_type == 'livret_a' %}Livret A
{% elif account.account_type == 'assurance_vie' %}Assurance Vie
{% else %}{{ account.account_type }}{% endif %}
— {{ "%.2f"|format(account.balance) }} €
</option>
{% endfor %}
</select>
</div>
<!-- Compte destination (virement interne) -->
<div class="form-group" id="toAccountGroup" style="display: none;">
<label class="form-label">
<i class="bi bi-wallet"></i> Compte destination *
</label>
<select name="to_account_id" class="form-select" id="toAccountSelect">
<option value="">-- Choisissez un compte --</option>
{% for account in accounts %}
<option value="{{ account.id }}">
{{ account.account_number }}
{% if account.account_type == 'courant' %}Compte Courant
{% elif account.account_type == 'livret_a' %}Livret A
{% elif account.account_type == 'assurance_vie' %}Assurance Vie
{% else %}{{ account.account_type }}{% endif %}
— {{ "%.2f"|format(account.balance) }} €
</option>
{% endfor %}
</select>
</div>
<!-- Bénéficiaire (virement entre personnes) -->
<div class="form-group" id="beneficiaryGroup" style="display: none;">
<label class="form-label">
<i class="bi bi-person"></i> Bénéficiaire *
</label>
{% if beneficiaries %}
<select name="beneficiary_id" class="form-select" id="beneficiarySelect">
<option value="">-- Choisissez un bénéficiaire --</option>
{% for b in beneficiaries %}
<option value="{{ b.id }}">
{{ b.beneficiary_name }} — {{ b.account_number }} ({{ b.bank_name }})
</option>
{% endfor %}
</select>
{% else %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-circle"></i>
Aucun bénéficiaire enregistré.
<a href="{{ url_for('add_beneficiary') }}" class="alert-link">
Ajouter un bénéficiaire
</a>
</div>
{% endif %}
</div>
<!-- Montant -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-currency-euro"></i> Montant (€) *
</label>
<input type="number" name="amount" class="form-control"
min="0.01" max="50000" step="0.01"
placeholder="0.00" required>
<small class="text-muted">Minimum 0,01 € — Maximum 50 000 €</small>
</div>
<!-- Libellé -->
<div class="form-group">
<label class="form-label">
<i class="bi bi-chat-text"></i> Libellé (optionnel)
</label>
<input type="text" name="description" class="form-control"
maxlength="200" placeholder="Ex: Remboursement restaurant">
</div>
<button type="submit" class="btn btn-dragon w-100 mt-3" id="submitBtn" disabled>
<i class="bi bi-send"></i> Effectuer le virement
</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-dragon-outline w-100 mt-2">
<i class="bi bi-arrow-left"></i> Retour au tableau de bord
</a>
</form>
</div>
<!-- Info si aucun compte -->
{% if not accounts %}
<div class="section-card text-center py-4 mt-3">
<i class="bi bi-wallet" style="font-size: 3rem; color: var(--dragon-text-light);"></i>
<h4 class="mt-3">Aucun compte disponible</h4>
<p class="text-muted">Vous devez posséder au moins un compte pour effectuer un virement.</p>
<a href="{{ url_for('open_account') }}" class="btn btn-dragon">
<i class="bi bi-plus-circle"></i> Ouvrir un compte
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const selectType = document.getElementById('transferType');
const groupToAccount = document.getElementById('toAccountGroup');
const groupBeneficiary = document.getElementById('beneficiaryGroup');
const selectToAccount = document.getElementById('toAccountSelect');
const selectBeneficiary = document.getElementById('beneficiarySelect');
const btnSubmit = document.getElementById('submitBtn');
// Affiche ou masque les champs selon le type de virement choisi
selectType.addEventListener('change', function () {
var type = this.value;
if (type === 'internal') {
groupToAccount.style.display = 'block';
groupBeneficiary.style.display = 'none';
if (selectToAccount) { selectToAccount.required = true; }
if (selectBeneficiary) { selectBeneficiary.required = false; }
} else if (type === 'person') {
groupToAccount.style.display = 'none';
groupBeneficiary.style.display = 'block';
if (selectToAccount) { selectToAccount.required = false; }
if (selectBeneficiary) { selectBeneficiary.required = true; }
} else {
groupToAccount.style.display = 'none';
groupBeneficiary.style.display = 'none';
if (selectToAccount) { selectToAccount.required = false; }
if (selectBeneficiary) { selectBeneficiary.required = false; }
}
btnSubmit.disabled = (type === '');
});
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff