Versione OK Pour l'onglet événements.
This commit is contained in:
@@ -4,6 +4,7 @@ import '../models/membre_model.dart';
|
||||
import '../models/cotisation_model.dart';
|
||||
import '../models/evenement_model.dart';
|
||||
import '../models/wave_checkout_session_model.dart';
|
||||
import '../models/payment_model.dart';
|
||||
import '../network/dio_client.dart';
|
||||
|
||||
/// Service API principal pour communiquer avec le serveur UnionFlow
|
||||
@@ -438,7 +439,7 @@ class ApiService {
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/evenements/a-venir',
|
||||
'/api/evenements/a-venir-public',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'size': size,
|
||||
@@ -640,4 +641,75 @@ class ApiService {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PAIEMENTS
|
||||
// ========================================
|
||||
|
||||
/// Initie un paiement
|
||||
Future<PaymentModel> initiatePayment(Map<String, dynamic> paymentData) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/paiements/initier', data: paymentData);
|
||||
return PaymentModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de l\'initiation du paiement');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le statut d'un paiement
|
||||
Future<PaymentModel> getPaymentStatus(String paymentId) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/paiements/$paymentId/statut');
|
||||
return PaymentModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la vérification du statut');
|
||||
}
|
||||
}
|
||||
|
||||
/// Annule un paiement
|
||||
Future<bool> cancelPayment(String paymentId) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/paiements/$paymentId/annuler');
|
||||
return response.statusCode == 200;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de l\'annulation du paiement');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'historique des paiements
|
||||
Future<List<PaymentModel>> getPaymentHistory(Map<String, dynamic> filters) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/paiements/historique', queryParameters: filters);
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => PaymentModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Format de réponse invalide pour l\'historique des paiements');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération de l\'historique');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie le statut d'un service de paiement
|
||||
Future<Map<String, dynamic>> checkServiceStatus(String serviceType) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/paiements/services/$serviceType/statut');
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la vérification du service');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les statistiques de paiement
|
||||
Future<Map<String, dynamic>> getPaymentStatistics(Map<String, dynamic> filters) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/paiements/statistiques', queryParameters: filters);
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
249
unionflow-mobile-apps/lib/core/services/cache_service.dart
Normal file
249
unionflow-mobile-apps/lib/core/services/cache_service.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
import 'dart:convert';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/cotisation_model.dart';
|
||||
import '../models/cotisation_statistics_model.dart';
|
||||
import '../models/payment_model.dart';
|
||||
|
||||
/// Service de gestion du cache local
|
||||
/// Permet de stocker et récupérer des données en mode hors-ligne
|
||||
@LazySingleton()
|
||||
class CacheService {
|
||||
static const String _cotisationsCacheKey = 'cotisations_cache';
|
||||
static const String _cotisationsStatsCacheKey = 'cotisations_stats_cache';
|
||||
static const String _paymentsCacheKey = 'payments_cache';
|
||||
static const String _lastSyncKey = 'last_sync_timestamp';
|
||||
static const Duration _cacheValidityDuration = Duration(minutes: 30);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
CacheService(this._prefs);
|
||||
|
||||
/// Sauvegarde une liste de cotisations dans le cache
|
||||
Future<void> saveCotisations(List<CotisationModel> cotisations, {String? key}) async {
|
||||
final cacheKey = key ?? _cotisationsCacheKey;
|
||||
final jsonList = cotisations.map((c) => c.toJson()).toList();
|
||||
final jsonString = jsonEncode({
|
||||
'data': jsonList,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
await _prefs.setString(cacheKey, jsonString);
|
||||
}
|
||||
|
||||
/// Récupère une liste de cotisations depuis le cache
|
||||
Future<List<CotisationModel>?> getCotisations({String? key}) async {
|
||||
final cacheKey = key ?? _cotisationsCacheKey;
|
||||
final jsonString = _prefs.getString(cacheKey);
|
||||
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
|
||||
|
||||
// Vérifier si le cache est encore valide
|
||||
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
|
||||
await clearCotisations(key: key);
|
||||
return null;
|
||||
}
|
||||
|
||||
final jsonList = jsonData['data'] as List<dynamic>;
|
||||
return jsonList.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>)).toList();
|
||||
} catch (e) {
|
||||
// En cas d'erreur, nettoyer le cache corrompu
|
||||
await clearCotisations(key: key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde les statistiques des cotisations
|
||||
Future<void> saveCotisationsStats(CotisationStatisticsModel stats) async {
|
||||
final jsonString = jsonEncode({
|
||||
'data': stats.toJson(),
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
await _prefs.setString(_cotisationsStatsCacheKey, jsonString);
|
||||
}
|
||||
|
||||
/// Récupère les statistiques des cotisations depuis le cache
|
||||
Future<CotisationStatisticsModel?> getCotisationsStats() async {
|
||||
final jsonString = _prefs.getString(_cotisationsStatsCacheKey);
|
||||
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
|
||||
|
||||
// Vérifier si le cache est encore valide
|
||||
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
|
||||
await clearCotisationsStats();
|
||||
return null;
|
||||
}
|
||||
|
||||
return CotisationStatisticsModel.fromJson(jsonData['data'] as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
await clearCotisationsStats();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde une liste de paiements dans le cache
|
||||
Future<void> savePayments(List<PaymentModel> payments) async {
|
||||
final jsonList = payments.map((p) => p.toJson()).toList();
|
||||
final jsonString = jsonEncode({
|
||||
'data': jsonList,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
await _prefs.setString(_paymentsCacheKey, jsonString);
|
||||
}
|
||||
|
||||
/// Récupère une liste de paiements depuis le cache
|
||||
Future<List<PaymentModel>?> getPayments() async {
|
||||
final jsonString = _prefs.getString(_paymentsCacheKey);
|
||||
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
|
||||
|
||||
// Vérifier si le cache est encore valide
|
||||
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
|
||||
await clearPayments();
|
||||
return null;
|
||||
}
|
||||
|
||||
final jsonList = jsonData['data'] as List<dynamic>;
|
||||
return jsonList.map((json) => PaymentModel.fromJson(json as Map<String, dynamic>)).toList();
|
||||
} catch (e) {
|
||||
await clearPayments();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde une cotisation individuelle dans le cache
|
||||
Future<void> saveCotisation(CotisationModel cotisation) async {
|
||||
final key = 'cotisation_${cotisation.id}';
|
||||
final jsonString = jsonEncode({
|
||||
'data': cotisation.toJson(),
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
await _prefs.setString(key, jsonString);
|
||||
}
|
||||
|
||||
/// Récupère une cotisation individuelle depuis le cache
|
||||
Future<CotisationModel?> getCotisation(String id) async {
|
||||
final key = 'cotisation_$id';
|
||||
final jsonString = _prefs.getString(key);
|
||||
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
|
||||
|
||||
// Vérifier si le cache est encore valide
|
||||
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
|
||||
await clearCotisation(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return CotisationModel.fromJson(jsonData['data'] as Map<String, dynamic>);
|
||||
} catch (e) {
|
||||
await clearCotisation(id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour le timestamp de la dernière synchronisation
|
||||
Future<void> updateLastSyncTimestamp() async {
|
||||
await _prefs.setInt(_lastSyncKey, DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
/// Récupère le timestamp de la dernière synchronisation
|
||||
DateTime? getLastSyncTimestamp() {
|
||||
final timestamp = _prefs.getInt(_lastSyncKey);
|
||||
return timestamp != null ? DateTime.fromMillisecondsSinceEpoch(timestamp) : null;
|
||||
}
|
||||
|
||||
/// Vérifie si une synchronisation est nécessaire
|
||||
bool needsSync() {
|
||||
final lastSync = getLastSyncTimestamp();
|
||||
if (lastSync == null) return true;
|
||||
|
||||
return DateTime.now().difference(lastSync) > const Duration(minutes: 15);
|
||||
}
|
||||
|
||||
/// Nettoie le cache des cotisations
|
||||
Future<void> clearCotisations({String? key}) async {
|
||||
final cacheKey = key ?? _cotisationsCacheKey;
|
||||
await _prefs.remove(cacheKey);
|
||||
}
|
||||
|
||||
/// Nettoie le cache des statistiques
|
||||
Future<void> clearCotisationsStats() async {
|
||||
await _prefs.remove(_cotisationsStatsCacheKey);
|
||||
}
|
||||
|
||||
/// Nettoie le cache des paiements
|
||||
Future<void> clearPayments() async {
|
||||
await _prefs.remove(_paymentsCacheKey);
|
||||
}
|
||||
|
||||
/// Nettoie une cotisation individuelle du cache
|
||||
Future<void> clearCotisation(String id) async {
|
||||
final key = 'cotisation_$id';
|
||||
await _prefs.remove(key);
|
||||
}
|
||||
|
||||
/// Nettoie tout le cache des cotisations
|
||||
Future<void> clearAllCotisationsCache() async {
|
||||
final keys = _prefs.getKeys().where((key) =>
|
||||
key.startsWith('cotisation') ||
|
||||
key == _cotisationsStatsCacheKey ||
|
||||
key == _paymentsCacheKey
|
||||
).toList();
|
||||
|
||||
for (final key in keys) {
|
||||
await _prefs.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la taille du cache en octets (approximation)
|
||||
int getCacheSize() {
|
||||
int totalSize = 0;
|
||||
final keys = _prefs.getKeys().where((key) =>
|
||||
key.startsWith('cotisation') ||
|
||||
key == _cotisationsStatsCacheKey ||
|
||||
key == _paymentsCacheKey
|
||||
);
|
||||
|
||||
for (final key in keys) {
|
||||
final value = _prefs.getString(key);
|
||||
if (value != null) {
|
||||
totalSize += value.length * 2; // Approximation UTF-16
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
/// Retourne des informations sur le cache
|
||||
Map<String, dynamic> getCacheInfo() {
|
||||
final lastSync = getLastSyncTimestamp();
|
||||
return {
|
||||
'lastSync': lastSync?.toIso8601String(),
|
||||
'needsSync': needsSync(),
|
||||
'cacheSize': getCacheSize(),
|
||||
'cacheSizeFormatted': _formatBytes(getCacheSize()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Formate la taille en octets en format lisible
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
}
|
||||
280
unionflow-mobile-apps/lib/core/services/moov_money_service.dart
Normal file
280
unionflow-mobile-apps/lib/core/services/moov_money_service.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/payment_model.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Service d'intégration avec Moov Money
|
||||
/// Gère les paiements via Moov Money pour la Côte d'Ivoire
|
||||
@LazySingleton()
|
||||
class MoovMoneyService {
|
||||
final ApiService _apiService;
|
||||
|
||||
MoovMoneyService(this._apiService);
|
||||
|
||||
/// Initie un paiement Moov Money pour une cotisation
|
||||
Future<PaymentModel> initiatePayment({
|
||||
required String cotisationId,
|
||||
required double montant,
|
||||
required String numeroTelephone,
|
||||
String? nomPayeur,
|
||||
String? emailPayeur,
|
||||
}) async {
|
||||
try {
|
||||
final paymentData = {
|
||||
'cotisationId': cotisationId,
|
||||
'montant': montant,
|
||||
'methodePaiement': 'MOOV_MONEY',
|
||||
'numeroTelephone': numeroTelephone,
|
||||
'nomPayeur': nomPayeur,
|
||||
'emailPayeur': emailPayeur,
|
||||
};
|
||||
|
||||
// Appel API pour initier le paiement Moov Money
|
||||
final payment = await _apiService.initiatePayment(paymentData);
|
||||
|
||||
return payment;
|
||||
} catch (e) {
|
||||
throw MoovMoneyException('Erreur lors de l\'initiation du paiement Moov Money: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie le statut d'un paiement Moov Money
|
||||
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
|
||||
try {
|
||||
return await _apiService.getPaymentStatus(paymentId);
|
||||
} catch (e) {
|
||||
throw MoovMoneyException('Erreur lors de la vérification du statut: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les frais Moov Money selon le barème officiel
|
||||
double calculateMoovMoneyFees(double montant) {
|
||||
// Barème Moov Money Côte d'Ivoire (2024)
|
||||
if (montant <= 1000) return 0; // Gratuit jusqu'à 1000 XOF
|
||||
if (montant <= 5000) return 30; // 30 XOF de 1001 à 5000
|
||||
if (montant <= 15000) return 75; // 75 XOF de 5001 à 15000
|
||||
if (montant <= 50000) return 150; // 150 XOF de 15001 à 50000
|
||||
if (montant <= 100000) return 300; // 300 XOF de 50001 à 100000
|
||||
if (montant <= 250000) return 600; // 600 XOF de 100001 à 250000
|
||||
if (montant <= 500000) return 1200; // 1200 XOF de 250001 à 500000
|
||||
|
||||
// Au-delà de 500000 XOF: 0.4% du montant
|
||||
return montant * 0.004;
|
||||
}
|
||||
|
||||
/// Valide un numéro de téléphone Moov Money
|
||||
bool validatePhoneNumber(String numeroTelephone) {
|
||||
// Nettoyer le numéro
|
||||
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
// Moov Money: 01, 02, 03 (Côte d'Ivoire)
|
||||
// Format: 225XXXXXXXX ou 0XXXXXXXX
|
||||
return RegExp(r'^(225)?(0[123])\d{8}$').hasMatch(cleanNumber);
|
||||
}
|
||||
|
||||
/// Obtient les limites de transaction Moov Money
|
||||
Map<String, double> getTransactionLimits() {
|
||||
return {
|
||||
'montantMinimum': 100.0, // 100 XOF minimum
|
||||
'montantMaximum': 1500000.0, // 1.5 million XOF maximum
|
||||
'fraisMinimum': 0.0,
|
||||
'fraisMaximum': 6000.0, // Frais maximum théorique
|
||||
};
|
||||
}
|
||||
|
||||
/// Vérifie si un montant est dans les limites autorisées
|
||||
bool isAmountValid(double montant) {
|
||||
final limits = getTransactionLimits();
|
||||
return montant >= limits['montantMinimum']! &&
|
||||
montant <= limits['montantMaximum']!;
|
||||
}
|
||||
|
||||
/// Formate un numéro de téléphone pour Moov Money
|
||||
String formatPhoneNumber(String numeroTelephone) {
|
||||
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
// Si le numéro commence par 225, le garder tel quel
|
||||
if (cleanNumber.startsWith('225')) {
|
||||
return cleanNumber;
|
||||
}
|
||||
|
||||
// Si le numéro commence par 0, ajouter 225
|
||||
if (cleanNumber.startsWith('0')) {
|
||||
return '225$cleanNumber';
|
||||
}
|
||||
|
||||
// Sinon, ajouter 2250
|
||||
return '2250$cleanNumber';
|
||||
}
|
||||
|
||||
/// Obtient les informations de l'opérateur
|
||||
Map<String, dynamic> getOperatorInfo() {
|
||||
return {
|
||||
'nom': 'Moov Money',
|
||||
'code': 'MOOV_MONEY',
|
||||
'couleur': '#0066CC',
|
||||
'icone': '💙',
|
||||
'description': 'Paiement via Moov Money',
|
||||
'prefixes': ['01', '02', '03'],
|
||||
'pays': 'Côte d\'Ivoire',
|
||||
'devise': 'XOF',
|
||||
};
|
||||
}
|
||||
|
||||
/// Génère un message de confirmation pour l'utilisateur
|
||||
String generateConfirmationMessage({
|
||||
required double montant,
|
||||
required String numeroTelephone,
|
||||
required double frais,
|
||||
}) {
|
||||
final total = montant + frais;
|
||||
final formattedPhone = formatPhoneNumber(numeroTelephone);
|
||||
|
||||
return '''
|
||||
Confirmation de paiement Moov Money
|
||||
|
||||
Montant: ${montant.toStringAsFixed(0)} XOF
|
||||
Frais: ${frais.toStringAsFixed(0)} XOF
|
||||
Total: ${total.toStringAsFixed(0)} XOF
|
||||
|
||||
Numéro: $formattedPhone
|
||||
|
||||
Vous allez recevoir un SMS avec le code de confirmation.
|
||||
Composez *155# pour finaliser le paiement.
|
||||
''';
|
||||
}
|
||||
|
||||
/// Annule un paiement Moov Money (si possible)
|
||||
Future<bool> cancelPayment(String paymentId) async {
|
||||
try {
|
||||
// Vérifier le statut du paiement
|
||||
final payment = await checkPaymentStatus(paymentId);
|
||||
|
||||
// Un paiement peut être annulé seulement s'il est en attente
|
||||
if (payment.statut == 'EN_ATTENTE') {
|
||||
// Appeler l'API d'annulation
|
||||
await _apiService.cancelPayment(paymentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient l'historique des paiements Moov Money
|
||||
Future<List<PaymentModel>> getPaymentHistory({
|
||||
String? cotisationId,
|
||||
DateTime? dateDebut,
|
||||
DateTime? dateFin,
|
||||
int? limit,
|
||||
}) async {
|
||||
try {
|
||||
final filters = <String, dynamic>{
|
||||
'methodePaiement': 'MOOV_MONEY',
|
||||
if (cotisationId != null) 'cotisationId': cotisationId,
|
||||
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
|
||||
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
|
||||
if (limit != null) 'limit': limit,
|
||||
};
|
||||
|
||||
return await _apiService.getPaymentHistory(filters);
|
||||
} catch (e) {
|
||||
throw MoovMoneyException('Erreur lors de la récupération de l\'historique: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie la disponibilité du service Moov Money
|
||||
Future<bool> checkServiceAvailability() async {
|
||||
try {
|
||||
// Appel API pour vérifier la disponibilité
|
||||
final response = await _apiService.checkServiceStatus('MOOV_MONEY');
|
||||
return response['available'] == true;
|
||||
} catch (e) {
|
||||
// En cas d'erreur, considérer le service comme indisponible
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient les statistiques des paiements Moov Money
|
||||
Future<Map<String, dynamic>> getPaymentStatistics({
|
||||
DateTime? dateDebut,
|
||||
DateTime? dateFin,
|
||||
}) async {
|
||||
try {
|
||||
final filters = <String, dynamic>{
|
||||
'methodePaiement': 'MOOV_MONEY',
|
||||
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
|
||||
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
|
||||
};
|
||||
|
||||
return await _apiService.getPaymentStatistics(filters);
|
||||
} catch (e) {
|
||||
throw MoovMoneyException('Erreur lors de la récupération des statistiques: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Détecte automatiquement l'opérateur à partir du numéro
|
||||
static String? detectOperatorFromNumber(String numeroTelephone) {
|
||||
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
// Extraire les 2 premiers chiffres après 225 ou le préfixe 0
|
||||
String prefix = '';
|
||||
if (cleanNumber.startsWith('225') && cleanNumber.length >= 5) {
|
||||
prefix = cleanNumber.substring(3, 5);
|
||||
} else if (cleanNumber.startsWith('0') && cleanNumber.length >= 2) {
|
||||
prefix = cleanNumber.substring(0, 2);
|
||||
}
|
||||
|
||||
// Vérifier si c'est Moov Money
|
||||
if (['01', '02', '03'].contains(prefix)) {
|
||||
return 'MOOV_MONEY';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Obtient les horaires de service
|
||||
Map<String, dynamic> getServiceHours() {
|
||||
return {
|
||||
'ouverture': '06:00',
|
||||
'fermeture': '23:00',
|
||||
'jours': ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'],
|
||||
'maintenance': {
|
||||
'debut': '02:00',
|
||||
'fin': '04:00',
|
||||
'description': 'Maintenance technique quotidienne'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Vérifie si le service est disponible à l'heure actuelle
|
||||
bool isServiceAvailableNow() {
|
||||
final now = DateTime.now();
|
||||
final hour = now.hour;
|
||||
|
||||
// Service disponible de 6h à 23h
|
||||
// Maintenance de 2h à 4h
|
||||
if (hour >= 2 && hour < 4) {
|
||||
return false; // Maintenance
|
||||
}
|
||||
|
||||
return hour >= 6 && hour < 23;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception personnalisée pour les erreurs Moov Money
|
||||
class MoovMoneyException implements Exception {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
final dynamic originalError;
|
||||
|
||||
MoovMoneyException(
|
||||
this.message, {
|
||||
this.errorCode,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'MoovMoneyException: $message';
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/cotisation_model.dart';
|
||||
|
||||
/// Service de gestion des notifications
|
||||
/// Gère les notifications locales et push pour les cotisations
|
||||
@LazySingleton()
|
||||
class NotificationService {
|
||||
static const String _notificationsEnabledKey = 'notifications_enabled';
|
||||
static const String _reminderDaysKey = 'reminder_days';
|
||||
static const String _scheduledNotificationsKey = 'scheduled_notifications';
|
||||
|
||||
final FlutterLocalNotificationsPlugin _localNotifications;
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
NotificationService(this._localNotifications, this._prefs);
|
||||
|
||||
/// Initialise le service de notifications
|
||||
Future<void> initialize() async {
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||
);
|
||||
|
||||
// Demander les permissions sur iOS
|
||||
await _requestPermissions();
|
||||
}
|
||||
|
||||
/// Demande les permissions de notification
|
||||
Future<bool> _requestPermissions() async {
|
||||
final result = await _localNotifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return result ?? true;
|
||||
}
|
||||
|
||||
/// Planifie une notification de rappel pour une cotisation
|
||||
Future<void> schedulePaymentReminder(CotisationModel cotisation) async {
|
||||
if (!await isNotificationsEnabled()) return;
|
||||
|
||||
final reminderDays = await getReminderDays();
|
||||
final notificationDate = cotisation.dateEcheance.subtract(Duration(days: reminderDays));
|
||||
|
||||
// Ne pas planifier si la date est déjà passée
|
||||
if (notificationDate.isBefore(DateTime.now())) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'payment_reminders',
|
||||
'Rappels de paiement',
|
||||
channelDescription: 'Notifications de rappel pour les cotisations à payer',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
color: Color(0xFF2196F3),
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
final notificationId = _generateNotificationId(cotisation.id, 'reminder');
|
||||
|
||||
await _localNotifications.zonedSchedule(
|
||||
notificationId,
|
||||
'Rappel de cotisation',
|
||||
'Votre cotisation ${cotisation.typeCotisation} de ${cotisation.montantDu.toStringAsFixed(0)} XOF arrive à échéance le ${_formatDate(cotisation.dateEcheance)}',
|
||||
_convertToTZDateTime(notificationDate),
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: jsonEncode({
|
||||
'type': 'payment_reminder',
|
||||
'cotisationId': cotisation.id,
|
||||
'action': 'open_cotisation',
|
||||
}),
|
||||
);
|
||||
|
||||
// Sauvegarder la notification planifiée
|
||||
await _saveScheduledNotification(notificationId, cotisation.id, 'reminder', notificationDate);
|
||||
}
|
||||
|
||||
/// Planifie une notification d'échéance le jour J
|
||||
Future<void> scheduleDueDateNotification(CotisationModel cotisation) async {
|
||||
if (!await isNotificationsEnabled()) return;
|
||||
|
||||
final notificationDate = DateTime(
|
||||
cotisation.dateEcheance.year,
|
||||
cotisation.dateEcheance.month,
|
||||
cotisation.dateEcheance.day,
|
||||
9, // 9h du matin
|
||||
);
|
||||
|
||||
// Ne pas planifier si la date est déjà passée
|
||||
if (notificationDate.isBefore(DateTime.now())) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'due_date_notifications',
|
||||
'Échéances du jour',
|
||||
channelDescription: 'Notifications pour les cotisations qui arrivent à échéance',
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
color: Color(0xFFFF5722),
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
ongoing: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
interruptionLevel: InterruptionLevel.critical,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
final notificationId = _generateNotificationId(cotisation.id, 'due_date');
|
||||
|
||||
await _localNotifications.zonedSchedule(
|
||||
notificationId,
|
||||
'Échéance aujourd\'hui !',
|
||||
'Votre cotisation ${cotisation.typeCotisation} de ${cotisation.montantDu.toStringAsFixed(0)} XOF arrive à échéance aujourd\'hui',
|
||||
_convertToTZDateTime(notificationDate),
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: jsonEncode({
|
||||
'type': 'due_date',
|
||||
'cotisationId': cotisation.id,
|
||||
'action': 'pay_now',
|
||||
}),
|
||||
);
|
||||
|
||||
await _saveScheduledNotification(notificationId, cotisation.id, 'due_date', notificationDate);
|
||||
}
|
||||
|
||||
/// Envoie une notification immédiate de confirmation de paiement
|
||||
Future<void> showPaymentConfirmation(CotisationModel cotisation, double montantPaye) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'payment_confirmations',
|
||||
'Confirmations de paiement',
|
||||
channelDescription: 'Notifications de confirmation après paiement',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
color: Color(0xFF4CAF50),
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
_generateNotificationId(cotisation.id, 'payment_success'),
|
||||
'Paiement confirmé ✅',
|
||||
'Votre paiement de ${montantPaye.toStringAsFixed(0)} XOF pour la cotisation ${cotisation.typeCotisation} a été confirmé',
|
||||
notificationDetails,
|
||||
payload: jsonEncode({
|
||||
'type': 'payment_success',
|
||||
'cotisationId': cotisation.id,
|
||||
'action': 'view_receipt',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Envoie une notification d'échec de paiement
|
||||
Future<void> showPaymentFailure(CotisationModel cotisation, String raison) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'payment_failures',
|
||||
'Échecs de paiement',
|
||||
channelDescription: 'Notifications d\'échec de paiement',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
color: Color(0xFFF44336),
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
_generateNotificationId(cotisation.id, 'payment_failure'),
|
||||
'Échec de paiement ❌',
|
||||
'Le paiement pour la cotisation ${cotisation.typeCotisation} a échoué: $raison',
|
||||
notificationDetails,
|
||||
payload: jsonEncode({
|
||||
'type': 'payment_failure',
|
||||
'cotisationId': cotisation.id,
|
||||
'action': 'retry_payment',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Annule toutes les notifications pour une cotisation
|
||||
Future<void> cancelCotisationNotifications(String cotisationId) async {
|
||||
final scheduledNotifications = await getScheduledNotifications();
|
||||
final notificationsToCancel = scheduledNotifications
|
||||
.where((n) => n['cotisationId'] == cotisationId)
|
||||
.toList();
|
||||
|
||||
for (final notification in notificationsToCancel) {
|
||||
await _localNotifications.cancel(notification['id'] as int);
|
||||
}
|
||||
|
||||
// Supprimer de la liste des notifications planifiées
|
||||
final updatedNotifications = scheduledNotifications
|
||||
.where((n) => n['cotisationId'] != cotisationId)
|
||||
.toList();
|
||||
|
||||
await _prefs.setString(_scheduledNotificationsKey, jsonEncode(updatedNotifications));
|
||||
}
|
||||
|
||||
/// Planifie les notifications pour toutes les cotisations actives
|
||||
Future<void> scheduleAllCotisationsNotifications(List<CotisationModel> cotisations) async {
|
||||
// Annuler toutes les notifications existantes
|
||||
await _localNotifications.cancelAll();
|
||||
await _clearScheduledNotifications();
|
||||
|
||||
// Planifier pour chaque cotisation non payée
|
||||
for (final cotisation in cotisations) {
|
||||
if (!cotisation.isEntierementPayee && !cotisation.isEnRetard) {
|
||||
await schedulePaymentReminder(cotisation);
|
||||
await scheduleDueDateNotification(cotisation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration des notifications
|
||||
|
||||
Future<bool> isNotificationsEnabled() async {
|
||||
return _prefs.getBool(_notificationsEnabledKey) ?? true;
|
||||
}
|
||||
|
||||
Future<void> setNotificationsEnabled(bool enabled) async {
|
||||
await _prefs.setBool(_notificationsEnabledKey, enabled);
|
||||
|
||||
if (!enabled) {
|
||||
await _localNotifications.cancelAll();
|
||||
await _clearScheduledNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getReminderDays() async {
|
||||
return _prefs.getInt(_reminderDaysKey) ?? 3; // 3 jours par défaut
|
||||
}
|
||||
|
||||
Future<void> setReminderDays(int days) async {
|
||||
await _prefs.setInt(_reminderDaysKey, days);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getScheduledNotifications() async {
|
||||
final jsonString = _prefs.getString(_scheduledNotificationsKey);
|
||||
if (jsonString == null) return [];
|
||||
|
||||
try {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonString);
|
||||
return jsonList.cast<Map<String, dynamic>>();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Méthodes privées
|
||||
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
if (response.payload != null) {
|
||||
try {
|
||||
final payload = jsonDecode(response.payload!);
|
||||
// TODO: Implémenter la navigation selon l'action
|
||||
// NavigationService.navigateToAction(payload);
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de parsing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _generateNotificationId(String cotisationId, String type) {
|
||||
return '${cotisationId}_$type'.hashCode;
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
// Note: Cette méthode nécessite le package timezone
|
||||
// Pour simplifier, on utilise DateTime directement
|
||||
dynamic _convertToTZDateTime(DateTime dateTime) {
|
||||
return dateTime; // Simplification - en production, utiliser TZDateTime
|
||||
}
|
||||
|
||||
Future<void> _saveScheduledNotification(
|
||||
int notificationId,
|
||||
String cotisationId,
|
||||
String type,
|
||||
DateTime scheduledDate,
|
||||
) async {
|
||||
final notifications = await getScheduledNotifications();
|
||||
notifications.add({
|
||||
'id': notificationId,
|
||||
'cotisationId': cotisationId,
|
||||
'type': type,
|
||||
'scheduledDate': scheduledDate.toIso8601String(),
|
||||
});
|
||||
|
||||
await _prefs.setString(_scheduledNotificationsKey, jsonEncode(notifications));
|
||||
}
|
||||
|
||||
Future<void> _clearScheduledNotifications() async {
|
||||
await _prefs.remove(_scheduledNotificationsKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/payment_model.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Service d'intégration avec Orange Money
|
||||
/// Gère les paiements via Orange Money pour la Côte d'Ivoire
|
||||
@LazySingleton()
|
||||
class OrangeMoneyService {
|
||||
final ApiService _apiService;
|
||||
|
||||
OrangeMoneyService(this._apiService);
|
||||
|
||||
/// Initie un paiement Orange Money pour une cotisation
|
||||
Future<PaymentModel> initiatePayment({
|
||||
required String cotisationId,
|
||||
required double montant,
|
||||
required String numeroTelephone,
|
||||
String? nomPayeur,
|
||||
String? emailPayeur,
|
||||
}) async {
|
||||
try {
|
||||
final paymentData = {
|
||||
'cotisationId': cotisationId,
|
||||
'montant': montant,
|
||||
'methodePaiement': 'ORANGE_MONEY',
|
||||
'numeroTelephone': numeroTelephone,
|
||||
'nomPayeur': nomPayeur,
|
||||
'emailPayeur': emailPayeur,
|
||||
};
|
||||
|
||||
// Appel API pour initier le paiement Orange Money
|
||||
final payment = await _apiService.initiatePayment(paymentData);
|
||||
|
||||
return payment;
|
||||
} catch (e) {
|
||||
throw OrangeMoneyException('Erreur lors de l\'initiation du paiement Orange Money: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie le statut d'un paiement Orange Money
|
||||
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
|
||||
try {
|
||||
return await _apiService.getPaymentStatus(paymentId);
|
||||
} catch (e) {
|
||||
throw OrangeMoneyException('Erreur lors de la vérification du statut: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les frais Orange Money selon le barème officiel
|
||||
double calculateOrangeMoneyFees(double montant) {
|
||||
// Barème Orange Money Côte d'Ivoire (2024)
|
||||
if (montant <= 1000) return 0; // Gratuit jusqu'à 1000 XOF
|
||||
if (montant <= 5000) return 25; // 25 XOF de 1001 à 5000
|
||||
if (montant <= 10000) return 50; // 50 XOF de 5001 à 10000
|
||||
if (montant <= 25000) return 100; // 100 XOF de 10001 à 25000
|
||||
if (montant <= 50000) return 200; // 200 XOF de 25001 à 50000
|
||||
if (montant <= 100000) return 400; // 400 XOF de 50001 à 100000
|
||||
if (montant <= 250000) return 750; // 750 XOF de 100001 à 250000
|
||||
if (montant <= 500000) return 1500; // 1500 XOF de 250001 à 500000
|
||||
|
||||
// Au-delà de 500000 XOF: 0.5% du montant
|
||||
return montant * 0.005;
|
||||
}
|
||||
|
||||
/// Valide un numéro de téléphone Orange Money
|
||||
bool validatePhoneNumber(String numeroTelephone) {
|
||||
// Nettoyer le numéro
|
||||
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
// Orange Money: 07, 08, 09 (Côte d'Ivoire)
|
||||
// Format: 225XXXXXXXX ou 0XXXXXXXX
|
||||
return RegExp(r'^(225)?(0[789])\d{8}$').hasMatch(cleanNumber);
|
||||
}
|
||||
|
||||
/// Obtient les limites de transaction Orange Money
|
||||
Map<String, double> getTransactionLimits() {
|
||||
return {
|
||||
'montantMinimum': 100.0, // 100 XOF minimum
|
||||
'montantMaximum': 1000000.0, // 1 million XOF maximum
|
||||
'fraisMinimum': 0.0,
|
||||
'fraisMaximum': 5000.0, // Frais maximum théorique
|
||||
};
|
||||
}
|
||||
|
||||
/// Vérifie si un montant est dans les limites autorisées
|
||||
bool isAmountValid(double montant) {
|
||||
final limits = getTransactionLimits();
|
||||
return montant >= limits['montantMinimum']! &&
|
||||
montant <= limits['montantMaximum']!;
|
||||
}
|
||||
|
||||
/// Formate un numéro de téléphone pour Orange Money
|
||||
String formatPhoneNumber(String numeroTelephone) {
|
||||
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
// Si le numéro commence par 225, le garder tel quel
|
||||
if (cleanNumber.startsWith('225')) {
|
||||
return cleanNumber;
|
||||
}
|
||||
|
||||
// Si le numéro commence par 0, ajouter 225
|
||||
if (cleanNumber.startsWith('0')) {
|
||||
return '225$cleanNumber';
|
||||
}
|
||||
|
||||
// Sinon, ajouter 2250
|
||||
return '2250$cleanNumber';
|
||||
}
|
||||
|
||||
/// Obtient les informations de l'opérateur
|
||||
Map<String, dynamic> getOperatorInfo() {
|
||||
return {
|
||||
'nom': 'Orange Money',
|
||||
'code': 'ORANGE_MONEY',
|
||||
'couleur': '#FF6600',
|
||||
'icone': '📱',
|
||||
'description': 'Paiement via Orange Money',
|
||||
'prefixes': ['07', '08', '09'],
|
||||
'pays': 'Côte d\'Ivoire',
|
||||
'devise': 'XOF',
|
||||
};
|
||||
}
|
||||
|
||||
/// Génère un message de confirmation pour l'utilisateur
|
||||
String generateConfirmationMessage({
|
||||
required double montant,
|
||||
required String numeroTelephone,
|
||||
required double frais,
|
||||
}) {
|
||||
final total = montant + frais;
|
||||
final formattedPhone = formatPhoneNumber(numeroTelephone);
|
||||
|
||||
return '''
|
||||
Confirmation de paiement Orange Money
|
||||
|
||||
Montant: ${montant.toStringAsFixed(0)} XOF
|
||||
Frais: ${frais.toStringAsFixed(0)} XOF
|
||||
Total: ${total.toStringAsFixed(0)} XOF
|
||||
|
||||
Numéro: $formattedPhone
|
||||
|
||||
Vous allez recevoir un SMS avec le code de confirmation.
|
||||
Suivez les instructions pour finaliser le paiement.
|
||||
''';
|
||||
}
|
||||
|
||||
/// Annule un paiement Orange Money (si possible)
|
||||
Future<bool> cancelPayment(String paymentId) async {
|
||||
try {
|
||||
// Vérifier le statut du paiement
|
||||
final payment = await checkPaymentStatus(paymentId);
|
||||
|
||||
// Un paiement peut être annulé seulement s'il est en attente
|
||||
if (payment.statut == 'EN_ATTENTE') {
|
||||
// Appeler l'API d'annulation
|
||||
await _apiService.cancelPayment(paymentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient l'historique des paiements Orange Money
|
||||
Future<List<PaymentModel>> getPaymentHistory({
|
||||
String? cotisationId,
|
||||
DateTime? dateDebut,
|
||||
DateTime? dateFin,
|
||||
int? limit,
|
||||
}) async {
|
||||
try {
|
||||
final filters = <String, dynamic>{
|
||||
'methodePaiement': 'ORANGE_MONEY',
|
||||
if (cotisationId != null) 'cotisationId': cotisationId,
|
||||
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
|
||||
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
|
||||
if (limit != null) 'limit': limit,
|
||||
};
|
||||
|
||||
return await _apiService.getPaymentHistory(filters);
|
||||
} catch (e) {
|
||||
throw OrangeMoneyException('Erreur lors de la récupération de l\'historique: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie la disponibilité du service Orange Money
|
||||
Future<bool> checkServiceAvailability() async {
|
||||
try {
|
||||
// Appel API pour vérifier la disponibilité
|
||||
final response = await _apiService.checkServiceStatus('ORANGE_MONEY');
|
||||
return response['available'] == true;
|
||||
} catch (e) {
|
||||
// En cas d'erreur, considérer le service comme indisponible
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient les statistiques des paiements Orange Money
|
||||
Future<Map<String, dynamic>> getPaymentStatistics({
|
||||
DateTime? dateDebut,
|
||||
DateTime? dateFin,
|
||||
}) async {
|
||||
try {
|
||||
final filters = <String, dynamic>{
|
||||
'methodePaiement': 'ORANGE_MONEY',
|
||||
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
|
||||
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
|
||||
};
|
||||
|
||||
return await _apiService.getPaymentStatistics(filters);
|
||||
} catch (e) {
|
||||
throw OrangeMoneyException('Erreur lors de la récupération des statistiques: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception personnalisée pour les erreurs Orange Money
|
||||
class OrangeMoneyException implements Exception {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
final dynamic originalError;
|
||||
|
||||
OrangeMoneyException(
|
||||
this.message, {
|
||||
this.errorCode,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'OrangeMoneyException: $message';
|
||||
}
|
||||
428
unionflow-mobile-apps/lib/core/services/payment_service.dart
Normal file
428
unionflow-mobile-apps/lib/core/services/payment_service.dart
Normal file
@@ -0,0 +1,428 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/payment_model.dart';
|
||||
import '../models/cotisation_model.dart';
|
||||
import 'api_service.dart';
|
||||
import 'cache_service.dart';
|
||||
import 'wave_payment_service.dart';
|
||||
import 'orange_money_service.dart';
|
||||
import 'moov_money_service.dart';
|
||||
|
||||
/// Service de gestion des paiements
|
||||
/// Gère les transactions de paiement avec différents opérateurs
|
||||
@LazySingleton()
|
||||
class PaymentService {
|
||||
final ApiService _apiService;
|
||||
final CacheService _cacheService;
|
||||
final WavePaymentService _waveService;
|
||||
final OrangeMoneyService _orangeService;
|
||||
final MoovMoneyService _moovService;
|
||||
|
||||
PaymentService(
|
||||
this._apiService,
|
||||
this._cacheService,
|
||||
this._waveService,
|
||||
this._orangeService,
|
||||
this._moovService,
|
||||
);
|
||||
|
||||
/// Initie un paiement pour une cotisation
|
||||
Future<PaymentModel> initiatePayment({
|
||||
required String cotisationId,
|
||||
required double montant,
|
||||
required String methodePaiement,
|
||||
required String numeroTelephone,
|
||||
String? nomPayeur,
|
||||
String? emailPayeur,
|
||||
}) async {
|
||||
try {
|
||||
PaymentModel payment;
|
||||
|
||||
// Déléguer au service spécialisé selon la méthode de paiement
|
||||
switch (methodePaiement) {
|
||||
case 'WAVE':
|
||||
payment = await _waveService.initiatePayment(
|
||||
cotisationId: cotisationId,
|
||||
montant: montant,
|
||||
numeroTelephone: numeroTelephone,
|
||||
nomPayeur: nomPayeur,
|
||||
emailPayeur: emailPayeur,
|
||||
);
|
||||
break;
|
||||
case 'ORANGE_MONEY':
|
||||
payment = await _orangeService.initiatePayment(
|
||||
cotisationId: cotisationId,
|
||||
montant: montant,
|
||||
numeroTelephone: numeroTelephone,
|
||||
nomPayeur: nomPayeur,
|
||||
emailPayeur: emailPayeur,
|
||||
);
|
||||
break;
|
||||
case 'MOOV_MONEY':
|
||||
payment = await _moovService.initiatePayment(
|
||||
cotisationId: cotisationId,
|
||||
montant: montant,
|
||||
numeroTelephone: numeroTelephone,
|
||||
nomPayeur: nomPayeur,
|
||||
emailPayeur: emailPayeur,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw PaymentException('Méthode de paiement non supportée: $methodePaiement');
|
||||
}
|
||||
|
||||
// Sauvegarder en cache
|
||||
await _cachePayment(payment);
|
||||
|
||||
return payment;
|
||||
} catch (e) {
|
||||
if (e is PaymentException) rethrow;
|
||||
throw PaymentException('Erreur lors de l\'initiation du paiement: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie le statut d'un paiement
|
||||
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
|
||||
try {
|
||||
// Essayer le cache d'abord
|
||||
final cachedPayment = await _getCachedPayment(paymentId);
|
||||
|
||||
// Si le paiement est déjà terminé (succès ou échec), retourner le cache
|
||||
if (cachedPayment != null &&
|
||||
(cachedPayment.isSuccessful || cachedPayment.isFailed)) {
|
||||
return cachedPayment;
|
||||
}
|
||||
|
||||
// Déterminer le service à utiliser selon la méthode de paiement
|
||||
PaymentModel payment;
|
||||
if (cachedPayment != null) {
|
||||
switch (cachedPayment.methodePaiement) {
|
||||
case 'WAVE':
|
||||
payment = await _waveService.checkPaymentStatus(paymentId);
|
||||
break;
|
||||
case 'ORANGE_MONEY':
|
||||
payment = await _orangeService.checkPaymentStatus(paymentId);
|
||||
break;
|
||||
case 'MOOV_MONEY':
|
||||
payment = await _moovService.checkPaymentStatus(paymentId);
|
||||
break;
|
||||
default:
|
||||
throw PaymentException('Méthode de paiement inconnue: ${cachedPayment.methodePaiement}');
|
||||
}
|
||||
} else {
|
||||
// Si pas de cache, essayer tous les services (peu probable)
|
||||
throw PaymentException('Paiement non trouvé en cache');
|
||||
}
|
||||
|
||||
// Mettre à jour le cache
|
||||
await _cachePayment(payment);
|
||||
|
||||
return payment;
|
||||
} catch (e) {
|
||||
// En cas d'erreur réseau, retourner le cache si disponible
|
||||
final cachedPayment = await _getCachedPayment(paymentId);
|
||||
if (cachedPayment != null) {
|
||||
return cachedPayment;
|
||||
}
|
||||
throw PaymentException('Erreur lors de la vérification du paiement: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Annule un paiement en cours
|
||||
Future<bool> cancelPayment(String paymentId) async {
|
||||
try {
|
||||
// Récupérer le paiement en cache pour connaître la méthode
|
||||
final cachedPayment = await _getCachedPayment(paymentId);
|
||||
if (cachedPayment == null) {
|
||||
throw PaymentException('Paiement non trouvé');
|
||||
}
|
||||
|
||||
// Déléguer au service approprié
|
||||
bool cancelled = false;
|
||||
switch (cachedPayment.methodePaiement) {
|
||||
case 'WAVE':
|
||||
cancelled = await _waveService.cancelPayment(paymentId);
|
||||
break;
|
||||
case 'ORANGE_MONEY':
|
||||
cancelled = await _orangeService.cancelPayment(paymentId);
|
||||
break;
|
||||
case 'MOOV_MONEY':
|
||||
cancelled = await _moovService.cancelPayment(paymentId);
|
||||
break;
|
||||
default:
|
||||
throw PaymentException('Méthode de paiement non supportée pour l\'annulation');
|
||||
}
|
||||
|
||||
return cancelled;
|
||||
} catch (e) {
|
||||
if (e is PaymentException) rethrow;
|
||||
throw PaymentException('Erreur lors de l\'annulation du paiement: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Retente un paiement échoué
|
||||
Future<PaymentModel> retryPayment(String paymentId) async {
|
||||
try {
|
||||
// Récupérer le paiement original
|
||||
final originalPayment = await _getCachedPayment(paymentId);
|
||||
if (originalPayment == null) {
|
||||
throw PaymentException('Paiement original non trouvé');
|
||||
}
|
||||
|
||||
// Réinitier le paiement avec les mêmes paramètres
|
||||
return await initiatePayment(
|
||||
cotisationId: originalPayment.cotisationId,
|
||||
montant: originalPayment.montant,
|
||||
methodePaiement: originalPayment.methodePaiement,
|
||||
numeroTelephone: originalPayment.numeroTelephone ?? '',
|
||||
nomPayeur: originalPayment.nomPayeur,
|
||||
emailPayeur: originalPayment.emailPayeur,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is PaymentException) rethrow;
|
||||
throw PaymentException('Erreur lors de la nouvelle tentative de paiement: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'historique des paiements d'une cotisation
|
||||
Future<List<PaymentModel>> getPaymentHistory(String cotisationId) async {
|
||||
try {
|
||||
// Essayer le cache d'abord
|
||||
final cachedPayments = await _cacheService.getPayments();
|
||||
if (cachedPayments != null) {
|
||||
final filteredPayments = cachedPayments
|
||||
.where((p) => p.cotisationId == cotisationId)
|
||||
.toList();
|
||||
|
||||
if (filteredPayments.isNotEmpty) {
|
||||
return filteredPayments;
|
||||
}
|
||||
}
|
||||
|
||||
// Si pas de cache, retourner une liste vide
|
||||
// En production, on pourrait appeler l'API ici
|
||||
return [];
|
||||
} catch (e) {
|
||||
throw PaymentException('Erreur lors de la récupération de l\'historique: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Valide les données de paiement avant envoi
|
||||
bool validatePaymentData({
|
||||
required String cotisationId,
|
||||
required double montant,
|
||||
required String methodePaiement,
|
||||
required String numeroTelephone,
|
||||
}) {
|
||||
// Validation du montant
|
||||
if (montant <= 0) return false;
|
||||
|
||||
// Validation du numéro de téléphone selon l'opérateur
|
||||
if (!_validatePhoneNumber(numeroTelephone, methodePaiement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation de la méthode de paiement
|
||||
if (!_isValidPaymentMethod(methodePaiement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Calcule les frais de transaction selon la méthode
|
||||
double calculateTransactionFees(double montant, String methodePaiement) {
|
||||
switch (methodePaiement) {
|
||||
case 'ORANGE_MONEY':
|
||||
return _calculateOrangeMoneyFees(montant);
|
||||
case 'WAVE':
|
||||
return _calculateWaveFees(montant);
|
||||
case 'MOOV_MONEY':
|
||||
return _calculateMoovMoneyFees(montant);
|
||||
case 'CARTE_BANCAIRE':
|
||||
return _calculateCardFees(montant);
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne les méthodes de paiement disponibles
|
||||
List<PaymentMethod> getAvailablePaymentMethods() {
|
||||
return [
|
||||
PaymentMethod(
|
||||
id: 'ORANGE_MONEY',
|
||||
nom: 'Orange Money',
|
||||
icone: '📱',
|
||||
couleur: '#FF6600',
|
||||
description: 'Paiement via Orange Money',
|
||||
fraisMinimum: 0,
|
||||
fraisMaximum: 1000,
|
||||
montantMinimum: 100,
|
||||
montantMaximum: 1000000,
|
||||
),
|
||||
PaymentMethod(
|
||||
id: 'WAVE',
|
||||
nom: 'Wave',
|
||||
icone: '🌊',
|
||||
couleur: '#00D4FF',
|
||||
description: 'Paiement via Wave',
|
||||
fraisMinimum: 0,
|
||||
fraisMaximum: 500,
|
||||
montantMinimum: 100,
|
||||
montantMaximum: 2000000,
|
||||
),
|
||||
PaymentMethod(
|
||||
id: 'MOOV_MONEY',
|
||||
nom: 'Moov Money',
|
||||
icone: '💙',
|
||||
couleur: '#0066CC',
|
||||
description: 'Paiement via Moov Money',
|
||||
fraisMinimum: 0,
|
||||
fraisMaximum: 800,
|
||||
montantMinimum: 100,
|
||||
montantMaximum: 1500000,
|
||||
),
|
||||
PaymentMethod(
|
||||
id: 'CARTE_BANCAIRE',
|
||||
nom: 'Carte bancaire',
|
||||
icone: '💳',
|
||||
couleur: '#4CAF50',
|
||||
description: 'Paiement par carte bancaire',
|
||||
fraisMinimum: 100,
|
||||
fraisMaximum: 2000,
|
||||
montantMinimum: 500,
|
||||
montantMaximum: 5000000,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Méthodes privées
|
||||
|
||||
Future<void> _cachePayment(PaymentModel payment) async {
|
||||
try {
|
||||
// Utiliser le service de cache pour sauvegarder
|
||||
final payments = await _cacheService.getPayments() ?? [];
|
||||
|
||||
// Remplacer ou ajouter le paiement
|
||||
final index = payments.indexWhere((p) => p.id == payment.id);
|
||||
if (index >= 0) {
|
||||
payments[index] = payment;
|
||||
} else {
|
||||
payments.add(payment);
|
||||
}
|
||||
|
||||
await _cacheService.savePayments(payments);
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de cache
|
||||
}
|
||||
}
|
||||
|
||||
Future<PaymentModel?> _getCachedPayment(String paymentId) async {
|
||||
try {
|
||||
final payments = await _cacheService.getPayments();
|
||||
if (payments != null) {
|
||||
return payments.firstWhere(
|
||||
(p) => p.id == paymentId,
|
||||
orElse: () => throw StateError('Payment not found'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _validatePhoneNumber(String numero, String operateur) {
|
||||
// Supprimer les espaces et caractères spéciaux
|
||||
final cleanNumber = numero.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
switch (operateur) {
|
||||
case 'ORANGE_MONEY':
|
||||
// Orange: 07, 08, 09 (Côte d'Ivoire)
|
||||
return RegExp(r'^(225)?(0[789])\d{8}$').hasMatch(cleanNumber);
|
||||
case 'WAVE':
|
||||
// Wave accepte tous les numéros ivoiriens
|
||||
return RegExp(r'^(225)?(0[1-9])\d{8}$').hasMatch(cleanNumber);
|
||||
case 'MOOV_MONEY':
|
||||
// Moov: 01, 02, 03
|
||||
return RegExp(r'^(225)?(0[123])\d{8}$').hasMatch(cleanNumber);
|
||||
default:
|
||||
return cleanNumber.length >= 8;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isValidPaymentMethod(String methode) {
|
||||
const validMethods = [
|
||||
'ORANGE_MONEY',
|
||||
'WAVE',
|
||||
'MOOV_MONEY',
|
||||
'CARTE_BANCAIRE',
|
||||
'VIREMENT',
|
||||
'ESPECES'
|
||||
];
|
||||
return validMethods.contains(methode);
|
||||
}
|
||||
|
||||
double _calculateOrangeMoneyFees(double montant) {
|
||||
if (montant <= 1000) return 0;
|
||||
if (montant <= 5000) return 25;
|
||||
if (montant <= 10000) return 50;
|
||||
if (montant <= 25000) return 100;
|
||||
if (montant <= 50000) return 200;
|
||||
return montant * 0.005; // 0.5%
|
||||
}
|
||||
|
||||
double _calculateWaveFees(double montant) {
|
||||
// Wave a généralement des frais plus bas
|
||||
if (montant <= 2000) return 0;
|
||||
if (montant <= 10000) return 25;
|
||||
if (montant <= 50000) return 100;
|
||||
return montant * 0.003; // 0.3%
|
||||
}
|
||||
|
||||
double _calculateMoovMoneyFees(double montant) {
|
||||
if (montant <= 1000) return 0;
|
||||
if (montant <= 5000) return 30;
|
||||
if (montant <= 15000) return 75;
|
||||
if (montant <= 50000) return 150;
|
||||
return montant * 0.004; // 0.4%
|
||||
}
|
||||
|
||||
double _calculateCardFees(double montant) {
|
||||
// Frais fixes + pourcentage pour les cartes
|
||||
return 100 + (montant * 0.025); // 100 XOF + 2.5%
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle pour les méthodes de paiement disponibles
|
||||
class PaymentMethod {
|
||||
final String id;
|
||||
final String nom;
|
||||
final String icone;
|
||||
final String couleur;
|
||||
final String description;
|
||||
final double fraisMinimum;
|
||||
final double fraisMaximum;
|
||||
final double montantMinimum;
|
||||
final double montantMaximum;
|
||||
|
||||
PaymentMethod({
|
||||
required this.id,
|
||||
required this.nom,
|
||||
required this.icone,
|
||||
required this.couleur,
|
||||
required this.description,
|
||||
required this.fraisMinimum,
|
||||
required this.fraisMaximum,
|
||||
required this.montantMinimum,
|
||||
required this.montantMaximum,
|
||||
});
|
||||
}
|
||||
|
||||
/// Exception personnalisée pour les erreurs de paiement
|
||||
class PaymentException implements Exception {
|
||||
final String message;
|
||||
PaymentException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'PaymentException: $message';
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/payment_model.dart';
|
||||
import '../models/wave_checkout_session_model.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Service d'intégration avec l'API Wave Money
|
||||
/// Gère les paiements via Wave Money pour la Côte d'Ivoire
|
||||
@LazySingleton()
|
||||
class WavePaymentService {
|
||||
final ApiService _apiService;
|
||||
|
||||
WavePaymentService(this._apiService);
|
||||
|
||||
/// Crée une session de checkout Wave via notre API backend
|
||||
Future<WaveCheckoutSessionModel> createCheckoutSession({
|
||||
required double montant,
|
||||
required String devise,
|
||||
required String successUrl,
|
||||
required String errorUrl,
|
||||
String? organisationId,
|
||||
String? membreId,
|
||||
String? typePaiement,
|
||||
String? description,
|
||||
String? referenceExterne,
|
||||
}) async {
|
||||
try {
|
||||
// Utiliser notre API backend
|
||||
return await _apiService.createWaveSession(
|
||||
montant: montant,
|
||||
devise: devise,
|
||||
successUrl: successUrl,
|
||||
errorUrl: errorUrl,
|
||||
organisationId: organisationId,
|
||||
membreId: membreId,
|
||||
typePaiement: typePaiement,
|
||||
description: description,
|
||||
);
|
||||
} catch (e) {
|
||||
throw WavePaymentException('Erreur lors de la création de la session Wave: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie le statut d'une session de checkout
|
||||
Future<WaveCheckoutSessionModel> getCheckoutSession(String sessionId) async {
|
||||
try {
|
||||
return await _apiService.getWaveSession(sessionId);
|
||||
} catch (e) {
|
||||
throw WavePaymentException('Erreur lors de la récupération de la session: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Initie un paiement Wave pour une cotisation
|
||||
Future<PaymentModel> initiatePayment({
|
||||
required String cotisationId,
|
||||
required double montant,
|
||||
required String numeroTelephone,
|
||||
String? nomPayeur,
|
||||
String? emailPayeur,
|
||||
}) async {
|
||||
try {
|
||||
// Générer les URLs de callback
|
||||
const successUrl = 'https://unionflow.app/payment/success';
|
||||
const errorUrl = 'https://unionflow.app/payment/error';
|
||||
|
||||
// Créer la session Wave
|
||||
final session = await createCheckoutSession(
|
||||
montant: montant,
|
||||
devise: 'XOF', // Franc CFA
|
||||
successUrl: successUrl,
|
||||
errorUrl: errorUrl,
|
||||
typePaiement: 'COTISATION',
|
||||
description: 'Paiement cotisation $cotisationId',
|
||||
referenceExterne: cotisationId,
|
||||
);
|
||||
|
||||
// Convertir en PaymentModel pour l'uniformité
|
||||
return PaymentModel(
|
||||
id: session.id ?? session.waveSessionId,
|
||||
cotisationId: cotisationId,
|
||||
numeroReference: session.waveSessionId,
|
||||
montant: montant,
|
||||
codeDevise: 'XOF',
|
||||
methodePaiement: 'WAVE',
|
||||
statut: _mapWaveStatusToPaymentStatus(session.statut),
|
||||
dateTransaction: DateTime.now(),
|
||||
numeroTransaction: session.waveSessionId,
|
||||
referencePaiement: session.referenceExterne,
|
||||
operateurMobileMoney: 'WAVE',
|
||||
numeroTelephone: numeroTelephone,
|
||||
nomPayeur: nomPayeur,
|
||||
emailPayeur: emailPayeur,
|
||||
metadonnees: {
|
||||
'wave_session_id': session.waveSessionId,
|
||||
'wave_checkout_url': session.waveUrl,
|
||||
'wave_status': session.statut,
|
||||
'cotisation_id': cotisationId,
|
||||
'numero_telephone': numeroTelephone,
|
||||
'source': 'unionflow_mobile',
|
||||
},
|
||||
dateCreation: DateTime.now(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is WavePaymentException) {
|
||||
rethrow;
|
||||
}
|
||||
throw WavePaymentException('Erreur lors de l\'initiation du paiement Wave: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie le statut d'un paiement Wave
|
||||
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
|
||||
try {
|
||||
final session = await getCheckoutSession(paymentId);
|
||||
|
||||
return PaymentModel(
|
||||
id: session.id ?? session.waveSessionId,
|
||||
cotisationId: session.referenceExterne ?? '',
|
||||
numeroReference: session.waveSessionId,
|
||||
montant: session.montant,
|
||||
codeDevise: session.devise,
|
||||
methodePaiement: 'WAVE',
|
||||
statut: _mapWaveStatusToPaymentStatus(session.statut),
|
||||
dateTransaction: session.dateModification ?? DateTime.now(),
|
||||
numeroTransaction: session.waveSessionId,
|
||||
referencePaiement: session.referenceExterne,
|
||||
operateurMobileMoney: 'WAVE',
|
||||
metadonnees: {
|
||||
'wave_session_id': session.waveSessionId,
|
||||
'wave_checkout_url': session.waveUrl,
|
||||
'wave_status': session.statut,
|
||||
'organisation_id': session.organisationId,
|
||||
'membre_id': session.membreId,
|
||||
'type_paiement': session.typePaiement,
|
||||
},
|
||||
dateCreation: session.dateCreation,
|
||||
dateModification: session.dateModification,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is WavePaymentException) {
|
||||
rethrow;
|
||||
}
|
||||
throw WavePaymentException('Erreur lors de la vérification du statut: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les frais Wave selon le barème officiel
|
||||
double calculateWaveFees(double montant) {
|
||||
// Barème Wave Côte d'Ivoire (2024)
|
||||
if (montant <= 2000) return 0; // Gratuit jusqu'à 2000 XOF
|
||||
if (montant <= 10000) return 25; // 25 XOF de 2001 à 10000
|
||||
if (montant <= 50000) return 100; // 100 XOF de 10001 à 50000
|
||||
if (montant <= 100000) return 200; // 200 XOF de 50001 à 100000
|
||||
if (montant <= 500000) return 500; // 500 XOF de 100001 à 500000
|
||||
|
||||
// Au-delà de 500000 XOF: 0.1% du montant
|
||||
return montant * 0.001;
|
||||
}
|
||||
|
||||
/// Valide un numéro de téléphone pour Wave
|
||||
bool validatePhoneNumber(String numeroTelephone) {
|
||||
// Nettoyer le numéro
|
||||
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
// Wave accepte tous les numéros ivoiriens
|
||||
// Format: 225XXXXXXXX ou 0XXXXXXXX
|
||||
return RegExp(r'^(225)?(0[1-9])\d{8}$').hasMatch(cleanNumber) ||
|
||||
RegExp(r'^[1-9]\d{7}$').hasMatch(cleanNumber); // Format court
|
||||
}
|
||||
|
||||
/// Obtient l'URL de checkout pour redirection
|
||||
String getCheckoutUrl(String sessionId) {
|
||||
return 'https://checkout.wave.com/checkout/$sessionId';
|
||||
}
|
||||
|
||||
/// Annule une session de paiement (si possible)
|
||||
Future<bool> cancelPayment(String sessionId) async {
|
||||
try {
|
||||
// Vérifier le statut de la session
|
||||
final session = await getCheckoutSession(sessionId);
|
||||
|
||||
// Une session peut être considérée comme annulée si elle a expiré
|
||||
return session.statut.toLowerCase() == 'expired' ||
|
||||
session.statut.toLowerCase() == 'cancelled' ||
|
||||
session.estExpiree;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Méthodes utilitaires privées
|
||||
|
||||
String _mapWaveStatusToPaymentStatus(String waveStatus) {
|
||||
switch (waveStatus.toLowerCase()) {
|
||||
case 'pending':
|
||||
case 'en_attente':
|
||||
return 'EN_ATTENTE';
|
||||
case 'successful':
|
||||
case 'completed':
|
||||
case 'success':
|
||||
case 'reussie':
|
||||
return 'REUSSIE';
|
||||
case 'failed':
|
||||
case 'echec':
|
||||
return 'ECHOUEE';
|
||||
case 'expired':
|
||||
case 'cancelled':
|
||||
case 'annulee':
|
||||
return 'ANNULEE';
|
||||
default:
|
||||
return 'EN_ATTENTE';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception personnalisée pour les erreurs Wave
|
||||
class WavePaymentException implements Exception {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
final dynamic originalError;
|
||||
|
||||
WavePaymentException(
|
||||
this.message, {
|
||||
this.errorCode,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'WavePaymentException: $message';
|
||||
}
|
||||
Reference in New Issue
Block a user