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
414 lines
16 KiB
Dart
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,
|
|
});
|
|
}
|