Versione OK Pour l'onglet événements.

This commit is contained in:
DahoudG
2025-09-15 20:15:34 +00:00
parent 8a619ee1bf
commit 12d514d866
73 changed files with 11508 additions and 674 deletions

View File

@@ -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');
}
}
}

View 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';
}
}

View 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';
}

View File

@@ -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);
}
}

View File

@@ -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';
}

View 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';
}

View File

@@ -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';
}