/// Implémentation du repository des cotisations via l'API backend library contribution_repository_impl; import 'package:injectable/injectable.dart'; import 'package:unionflow_mobile_apps/core/network/api_client.dart'; import 'package:unionflow_mobile_apps/core/utils/logger.dart'; import '../../domain/repositories/contribution_repository.dart'; import '../models/contribution_model.dart'; /// Implémentation du repository des cotisations - appels API réels vers /api/cotisations @LazySingleton(as: IContributionRepository) class ContributionRepositoryImpl implements IContributionRepository { final ApiClient _apiClient; static const String _baseUrl = '/api/cotisations'; ContributionRepositoryImpl(this._apiClient); /// Toutes les cotisations du membre connecté (GET /api/cotisations/mes-cotisations). Future getMesCotisations({int page = 0, int size = 50}) async { final response = await _apiClient.get( '$_baseUrl/mes-cotisations', queryParameters: {'page': page, 'size': size}, ); if (response.statusCode != 200) { throw Exception( 'Erreur lors de la récupération des cotisations: ${response.statusCode}', ); } final data = response.data; final List list = data is List ? data as List : []; final contributions = list.map((e) => _summaryToModel(e as Map)).toList(); return ContributionPageResult( contributions: contributions, total: contributions.length, page: page, size: size, totalPages: list.isEmpty ? 0 : 1, ); } /// Récupère les cotisations en attente du membre connecté (endpoint dédié). Future getMesCotisationsEnAttente() async { final path = '$_baseUrl/mes-cotisations/en-attente'; final response = await _apiClient.get(path); if (response.statusCode != 200) { throw Exception( 'Erreur lors de la récupération des cotisations: ${response.statusCode}', ); } final data = response.data; final List list = data is List ? data : (data is Map ? (data['data'] ?? data['content'] ?? []) as List? ?? [] : []); final contributions = list .map((e) => _summaryToModel(e as Map)) .toList(); return ContributionPageResult( contributions: contributions, total: contributions.length, page: 0, size: contributions.length, totalPages: contributions.isEmpty ? 0 : 1, ); } static ContributionModel _summaryToModel(Map json) { final id = json['id']?.toString(); final statutStr = json['statut'] as String? ?? 'EN_ATTENTE'; final statut = _mapStatut(statutStr); final montantDu = (json['montantDu'] as num?)?.toDouble() ?? 0.0; final montantPaye = (json['montantPaye'] as num?)?.toDouble(); final dateEcheanceStr = json['dateEcheance'] as String?; final dateEcheance = dateEcheanceStr != null ? DateTime.tryParse(dateEcheanceStr) ?? DateTime.now() : DateTime.now(); final annee = (json['annee'] as num?)?.toInt() ?? dateEcheance.year; return ContributionModel( id: id, membreId: '', // membre implicite (endpoint "mes cotisations") membreNom: (json['nomMembre'] ?? json['nomCompletMembre']) as String?, type: ContributionType.annuelle, statut: statut, montant: montantDu, montantPaye: montantPaye, devise: 'XOF', dateEcheance: dateEcheance, annee: annee, ); } static ContributionStatus _mapStatut(String code) { switch (code.toUpperCase()) { case 'PAYEE': return ContributionStatus.payee; case 'EN_RETARD': return ContributionStatus.enRetard; case 'PARTIELLE': return ContributionStatus.partielle; case 'ANNULEE': return ContributionStatus.annulee; case 'EN_ATTENTE': case 'NON_PAYEE': default: return ContributionStatus.nonPayee; } } /// Récupère la liste des cotisations avec pagination (toutes cotisations, nécessite droits admin) Future getCotisations({ int page = 0, int size = 20, String? membreId, String? statut, String? type, int? annee, }) async { final queryParams = { 'page': page, 'size': size, }; if (membreId != null) queryParams['membreId'] = membreId; if (statut != null) queryParams['statut'] = statut; if (type != null) queryParams['type'] = type; if (annee != null) queryParams['annee'] = annee; final response = await _apiClient.get( _baseUrl, queryParameters: queryParams, ); if (response.statusCode == 200) { final data = response.data; if (data is List) { final contributions = data .map((json) => ContributionModel.fromJson(json as Map)) .toList(); return ContributionPageResult( contributions: contributions, total: contributions.length, page: page, size: size, totalPages: 1, ); } else if (data is Map) { final List content = data['content'] ?? data['items'] ?? []; final contributions = content .map((json) => ContributionModel.fromJson(json as Map)) .toList(); return ContributionPageResult( contributions: contributions, total: data['totalElements'] ?? data['total'] ?? contributions.length, page: data['number'] ?? page, size: data['size'] ?? size, totalPages: data['totalPages'] ?? 1, ); } } throw Exception('Erreur lors de la récupération des cotisations: ${response.statusCode}'); } /// Récupère une cotisation par ID Future getCotisationById(String id) async { final response = await _apiClient.get('$_baseUrl/$id'); if (response.statusCode == 200) { return ContributionModel.fromJson(response.data as Map); } throw Exception('Cotisation non trouvée'); } /// Crée une nouvelle cotisation (payload conforme au backend CreateCotisationRequest) Future createCotisation(ContributionModel contribution) async { final body = _toCreateCotisationRequest(contribution); final response = await _apiClient.post(_baseUrl, data: body); if (response.statusCode == 201 || response.statusCode == 200) { final data = Map.from(response.data as Map); _normalizeCotisationResponse(data); return ContributionModel.fromJson(data); } final message = response.data is Map ? (response.data as Map)['error'] ?? response.data.toString() : response.data?.toString() ?? 'Erreur ${response.statusCode}'; throw Exception('Erreur lors de la création: $message'); } /// Construit le body attendu par POST /api/cotisations (CreateCotisationRequest) static Map _toCreateCotisationRequest(ContributionModel c) { if (c.organisationId == null || c.organisationId!.trim().isEmpty) { throw Exception('L\'organisation du membre est requise pour créer une cotisation.'); } final typeStr = _contributionTypeToBackend(c.type); final dateStr = _formatLocalDate(c.dateEcheance); final desc = c.description?.trim(); final libelle = desc != null && desc.isNotEmpty ? (desc.length > 100 ? desc.substring(0, 100) : desc) : 'Cotisation $typeStr ${c.annee}'; final description = desc != null && desc.isNotEmpty ? (desc.length > 500 ? desc.substring(0, 500) : desc) : null; return { 'membreId': c.membreId, 'organisationId': c.organisationId!.trim(), 'typeCotisation': typeStr, 'libelle': libelle, if (description != null) 'description': description, 'montantDu': c.montant, 'codeDevise': c.devise.length == 3 ? c.devise : 'XOF', 'dateEcheance': dateStr, 'periode': '${_monthName(c.dateEcheance.month)} ${c.dateEcheance.year}', 'annee': c.annee, 'mois': c.mois ?? c.dateEcheance.month, 'recurrente': false, if (c.notes != null && c.notes!.isNotEmpty) 'observations': c.notes, }; } static String _contributionTypeToBackend(ContributionType t) { switch (t) { case ContributionType.mensuelle: return 'MENSUELLE'; case ContributionType.trimestrielle: return 'TRIMESTRIELLE'; case ContributionType.semestrielle: return 'SEMESTRIELLE'; case ContributionType.annuelle: return 'ANNUELLE'; case ContributionType.exceptionnelle: return 'EXCEPTIONNELLE'; } } static String _formatLocalDate(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; static String _monthName(int month) { const names = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; return month >= 1 && month <= 12 ? names[month - 1] : 'Mois $month'; } /// Adapte les clés de la réponse backend (CotisationResponse) vers le modèle mobile static void _normalizeCotisationResponse(Map data) { if (data.containsKey('nomMembre') && !data.containsKey('membreNom')) data['membreNom'] = data['nomMembre']; if (data.containsKey('nomOrganisation') && !data.containsKey('organisationNom')) data['organisationNom'] = data['nomOrganisation']; if (data.containsKey('codeDevise') && !data.containsKey('devise')) data['devise'] = data['codeDevise']; if (data.containsKey('montantDu') && !data.containsKey('montant')) data['montant'] = data['montantDu']; if (data['id'] != null && data['id'] is! String) data['id'] = data['id'].toString(); if (data['membreId'] != null && data['membreId'] is! String) data['membreId'] = data['membreId'].toString(); if (data['organisationId'] != null && data['organisationId'] is! String) data['organisationId'] = data['organisationId'].toString(); } /// Met à jour une cotisation Future updateCotisation(String id, ContributionModel contribution) async { final response = await _apiClient.put('$_baseUrl/$id', data: contribution.toJson()); if (response.statusCode == 200) { return ContributionModel.fromJson(response.data as Map); } throw Exception('Erreur lors de la mise à jour: ${response.statusCode}'); } /// Supprime une cotisation Future deleteCotisation(String id) async { final response = await _apiClient.delete('$_baseUrl/$id'); if (response.statusCode != 200 && response.statusCode != 204) { throw Exception('Erreur lors de la suppression: ${response.statusCode}'); } } /// Initie un paiement en ligne (Wave Checkout API). /// Retourne l'URL à ouvrir (wave_launch_url) pour que le membre confirme dans l'app Wave. /// Spec: https://docs.wave.com/checkout Future initierPaiementEnLigne({ required String cotisationId, required String methodePaiement, required String numeroTelephone, }) async { final response = await _apiClient.post( '/api/paiements/initier-paiement-en-ligne', data: { 'cotisationId': cotisationId, 'methodePaiement': methodePaiement, 'numeroTelephone': numeroTelephone.replaceAll(RegExp(r'\D'), ''), }, ); if (response.statusCode != 201 && response.statusCode != 200) { final msg = response.data is Map ? (response.data['message'] ?? response.data['error'] ?? response.statusCode) : response.statusCode; throw Exception('Impossible d\'initier le paiement: $msg'); } final data = response.data is Map ? response.data as Map : Map.from(response.data as Map); return WavePaiementInitResult( redirectUrl: data['redirectUrl'] as String? ?? data['waveLaunchUrl'] as String? ?? '', waveLaunchUrl: data['waveLaunchUrl'] as String? ?? data['redirectUrl'] as String? ?? '', waveCheckoutSessionId: data['waveCheckoutSessionId'] as String?, clientReference: data['clientReference'] as String?, intentionPaiementId: data['intentionPaiementId'] as String? ?? data['clientReference'] as String?, message: data['message'] as String? ?? 'Ouvrez Wave pour confirmer le paiement.', ); } /// Enregistre un paiement Future enregistrerPaiement( String cotisationId, { required double montant, required DateTime datePaiement, required String methodePaiement, String? numeroPaiement, String? referencePaiement, }) async { final response = await _apiClient.post( '$_baseUrl/$cotisationId/paiement', data: { 'montant': montant, 'datePaiement': datePaiement.toIso8601String(), 'methodePaiement': methodePaiement, if (numeroPaiement != null) 'numeroPaiement': numeroPaiement, if (referencePaiement != null) 'referencePaiement': referencePaiement, }, ); if (response.statusCode == 200) { return ContributionModel.fromJson(response.data as Map); } throw Exception('Erreur lors de l\'enregistrement du paiement: ${response.statusCode}'); } /// Synthèse personnelle du membre connecté (GET /api/cotisations/mes-cotisations/synthese) Future?> getMesCotisationsSynthese() async { try { final response = await _apiClient.get('$_baseUrl/mes-cotisations/synthese'); if (response.statusCode == 200 && response.data != null) { final data = response.data is Map ? response.data as Map : Map.from(response.data as Map); data['isMesSynthese'] = true; return data; } return null; } catch (e, st) { AppLogger.error('ContributionRepository: getMesCotisationsSynthese échoué', error: e, stackTrace: st); rethrow; } } /// Récupère les statistiques des cotisations (globales ou mes selon usage) Future> getStatistiques() async { final response = await _apiClient.get('$_baseUrl/statistiques'); if (response.statusCode == 200) { return response.data as Map; } throw Exception('Erreur lors de la récupération des statistiques'); } /// Envoie un rappel de paiement Future envoyerRappel(String cotisationId) async { final response = await _apiClient.post('$_baseUrl/$cotisationId/rappel'); if (response.statusCode != 200) { throw Exception('Erreur lors de l\'envoi du rappel'); } } /// Génère les cotisations annuelles Future genererCotisationsAnnuelles(int annee) async { final response = await _apiClient.post( '$_baseUrl/generer', data: {'annee': annee}, ); if (response.statusCode == 200) { return response.data['nombreGenere'] ?? 0; } throw Exception('Erreur lors de la génération'); } /// Confirme un paiement mock (mode dev) côté backend. /// Appelle GET /api/wave-redirect/mock-complete?ref={id} Future confirmerMockPaiement(String ref) async { await _apiClient.get('/api/wave-redirect/mock-complete', queryParameters: {'ref': ref}); } } /// Résultat de l'initiation d'un paiement Wave (redirection vers l'app Wave). class WavePaiementInitResult { final String redirectUrl; final String waveLaunchUrl; final String? waveCheckoutSessionId; final String? clientReference; final String? intentionPaiementId; final String message; const WavePaiementInitResult({ required this.redirectUrl, required this.waveLaunchUrl, this.waveCheckoutSessionId, this.clientReference, this.intentionPaiementId, required this.message, }); } /// Résultat paginé de cotisations class ContributionPageResult { final List contributions; final int total; final int page; final int size; final int totalPages; const ContributionPageResult({ required this.contributions, required this.total, required this.page, required this.size, required this.totalPages, }); }