Files
unionflow-mobile-apps/lib/features/contributions/data/repositories/contribution_repository.dart
dahoud 5dd6535191 fix: corriger 5 erreurs compilation mock-complete
1. confirmerMockPaiement() placé HORS de la classe → déplacé à l'intérieur
   (contribution_repository.dart + transaction_epargne_repository.dart)
2. result.clientReference?.isNotEmpty → null-safe avec ?.
3. IContributionRepository n'a pas confirmerMockPaiement → cast vers
   ContributionRepository (implémentation) avec check is
4. Import data/repositories ajouté dans payment_dialog.dart
2026-04-16 19:46:06 +00:00

414 lines
16 KiB
Dart

/// 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<ContributionPageResult> 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<dynamic> list = data is List ? data as List<dynamic> : <dynamic>[];
final contributions = list.map((e) => _summaryToModel(e as Map<String, dynamic>)).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<ContributionPageResult> 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<dynamic> list = data is List ? data : (data is Map ? (data['data'] ?? data['content'] ?? []) as List<dynamic>? ?? [] : []);
final contributions = list
.map((e) => _summaryToModel(e as Map<String, dynamic>))
.toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: 0,
size: contributions.length,
totalPages: contributions.isEmpty ? 0 : 1,
);
}
static ContributionModel _summaryToModel(Map<String, dynamic> 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<ContributionPageResult> getCotisations({
int page = 0,
int size = 20,
String? membreId,
String? statut,
String? type,
int? annee,
}) async {
final queryParams = <String, dynamic>{
'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<String, dynamic>))
.toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: page,
size: size,
totalPages: 1,
);
} else if (data is Map<String, dynamic>) {
final List<dynamic> content = data['content'] ?? data['items'] ?? [];
final contributions = content
.map((json) => ContributionModel.fromJson(json as Map<String, dynamic>))
.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<ContributionModel> getCotisationById(String id) async {
final response = await _apiClient.get('$_baseUrl/$id');
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Cotisation non trouvée');
}
/// Crée une nouvelle cotisation (payload conforme au backend CreateCotisationRequest)
Future<ContributionModel> 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<String, dynamic>.from(response.data as Map<String, dynamic>);
_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<String, dynamic> _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<String, dynamic> 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<ContributionModel> 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<String, dynamic>);
}
throw Exception('Erreur lors de la mise à jour: ${response.statusCode}');
}
/// Supprime une cotisation
Future<void> 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<WavePaiementInitResult> 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<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.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<ContributionModel> 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<String, dynamic>);
}
throw Exception('Erreur lors de l\'enregistrement du paiement: ${response.statusCode}');
}
/// Synthèse personnelle du membre connecté (GET /api/cotisations/mes-cotisations/synthese)
Future<Map<String, dynamic>?> 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<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.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<Map<String, dynamic>> getStatistiques() async {
final response = await _apiClient.get('$_baseUrl/statistiques');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
throw Exception('Erreur lors de la récupération des statistiques');
}
/// Envoie un rappel de paiement
Future<void> 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<int> 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<void> 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<ContributionModel> 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,
});
}