Files
unionflow-mobile-apps/lib/features/epargne/presentation/widgets/depot_epargne_dialog.dart
dahoud 22fec56fcf fix(wave-mock): appeler mock-complete backend pour créditer réellement les comptes
Le mock simulait seulement l'UI mais n'appelait pas le backend pour
confirmer le paiement → le solde restait à 0 après dépôt simulé.

Fix : en mode mock, après la simulation UI, appeler
GET /api/wave-redirect/mock-complete?ref={id} qui :
- Marque l'intention de paiement comme complétée
- Crédite le compte épargne (pour les dépôts)
- Enregistre le paiement de cotisation (pour les cotisations)

Changements :
- depot_epargne_dialog : appelle confirmerMockPaiement(versementId)
- payment_dialog : appelle confirmerMockPaiement(clientReference)
- transaction_epargne_repository : nouvelle méthode confirmerMockPaiement()
  + versementId ajouté à DepotWaveResult
- contribution_repository : nouvelle méthode confirmerMockPaiement()
  + intentionPaiementId ajouté à WavePaiementInitResult
2026-04-16 19:39:27 +00:00

426 lines
15 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:file_picker/file_picker.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/constants/lcb_ft_constants.dart';
import '../../../../core/data/repositories/parametres_lcb_ft_repository.dart';
import '../../../../core/utils/error_formatter.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
import '../../data/models/transaction_epargne_request.dart';
import '../../data/repositories/transaction_epargne_repository.dart';
import '../../data/services/document_upload_service.dart';
/// Dialogue de dépôt sur un compte épargne.
/// Deux modes : enregistrement manuel (LCB-FT) ou paiement via Wave (mobile money, même flux que cotisations).
class DepotEpargneDialog extends StatefulWidget {
final String compteId;
final VoidCallback? onSuccess;
const DepotEpargneDialog({
super.key,
required this.compteId,
this.onSuccess,
});
@override
State<DepotEpargneDialog> createState() => _DepotEpargneDialogState();
}
enum _DepotMode { manual, wave }
class _DepotEpargneDialogState extends State<DepotEpargneDialog> {
final _formKey = GlobalKey<FormState>();
final _montantController = TextEditingController();
final _motifController = TextEditingController();
final _origineFondsController = TextEditingController();
final _wavePhoneController = TextEditingController();
bool _loading = false;
bool _waveLoading = false;
bool _uploadingDocument = false;
_DepotMode _mode = _DepotMode.manual;
late TransactionEpargneRepository _repository;
late ParametresLcbFtRepository _parametresRepository;
late DocumentUploadService _uploadService;
/// Seuil LCB-FT récupéré depuis l'API (fallback à 500k XOF).
double _seuilLcbFt = kSeuilOrigineFondsObligatoireXOF;
/// Pièce justificative pour opérations au-dessus du seuil
File? _pieceJustificative;
String? _pieceJustificativeId;
@override
void initState() {
super.initState();
_repository = GetIt.I<TransactionEpargneRepository>();
_parametresRepository = GetIt.I<ParametresLcbFtRepository>();
_uploadService = GetIt.I<DocumentUploadService>();
_chargerSeuil();
}
/// Charge le seuil LCB-FT depuis l'API au chargement du dialog.
Future<void> _chargerSeuil() async {
final seuil = await _parametresRepository.getSeuilJustification();
if (mounted) {
setState(() {
_seuilLcbFt = seuil.montantSeuil;
});
}
}
bool get _origineFondsRequis {
final m = double.tryParse(_montantController.text.replaceAll(',', '.'));
return m != null && m >= _seuilLcbFt;
}
@override
void dispose() {
_montantController.dispose();
_motifController.dispose();
_origineFondsController.dispose();
_wavePhoneController.dispose();
super.dispose();
}
/// Choisir et uploader une pièce justificative (photo ou PDF)
Future<void> _choisirPieceJustificative() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final file = File(result.files.single.path!);
setState(() {
_uploadingDocument = true;
_pieceJustificative = file;
});
// Upload du fichier
final documentId = await _uploadService.uploadDocument(
file: file,
description: 'Pièce justificative - Dépôt épargne',
typeDocument: 'PIECE_JUSTIFICATIVE',
);
if (!mounted) return;
setState(() {
_pieceJustificativeId = documentId;
_uploadingDocument = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('✓ Pièce justificative uploadée avec succès'),
backgroundColor: AppColors.success,
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_uploadingDocument = false;
_pieceJustificative = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur upload : ${e.toString()}'),
backgroundColor: AppColors.error,
),
);
}
}
Future<void> _submitWave() async {
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Indiquez un montant valide')),
);
return;
}
final phone = _wavePhoneController.text.replaceAll(RegExp(r'\D'), '');
if (phone.length < 9) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Indiquez votre numéro Wave (9 chiffres)')),
);
return;
}
setState(() => _waveLoading = true);
try {
final result = await _repository.initierDepotEpargneEnLigne(
compteId: widget.compteId,
montant: montant,
numeroTelephone: phone,
);
final url = result.waveLaunchUrl.isNotEmpty ? result.waveLaunchUrl : result.redirectUrl;
if (url.isEmpty) throw Exception('URL Wave non reçue');
// Mode dev/mock : simuler le paiement ET confirmer côté backend
final isMock = url.contains('mock') || url.contains('localhost') || !AppConfig.isProd;
if (isMock) {
// 1. Appeler le mock-complete backend pour réellement créditer le compte
try {
await _repository.confirmerMockPaiement(result.versementId);
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
Navigator.of(context).pop(true);
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(children: [
Icon(Icons.science_rounded, color: Colors.white, size: 16),
SizedBox(width: 8),
Expanded(child: Text('Dépôt simulé et crédité (mode dev)')),
]),
backgroundColor: AppColors.success,
),
);
return;
}
// Mode prod : ouvrir Wave
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
await launchUrl(uri);
}
if (!mounted) return;
Navigator.of(context).pop(true);
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.message)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ErrorFormatter.format(e)),
duration: ErrorFormatter.isLcbFtError(e) ? const Duration(seconds: 6) : const Duration(seconds: 3),
),
);
} finally {
if (mounted) setState(() => _waveLoading = false);
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Montant invalide')),
);
return;
}
if (_origineFondsRequis && (_origineFondsController.text.trim().isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'L\'origine des fonds est obligatoire pour les opérations à partir de ${_seuilLcbFt.toStringAsFixed(0)} XOF (LCB-FT).',
),
),
);
return;
}
if (_origineFondsRequis && _pieceJustificativeId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Une pièce justificative est requise pour les opérations ≥ ${_seuilLcbFt.toStringAsFixed(0)} XOF (LCB-FT).',
),
duration: const Duration(seconds: 4),
),
);
return;
}
setState(() => _loading = true);
try {
final request = TransactionEpargneRequest(
compteId: widget.compteId,
typeTransaction: 'DEPOT',
montant: montant,
motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(),
origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(),
pieceJustificativeId: _pieceJustificativeId,
);
await _repository.executer(request);
if (!mounted) return;
Navigator.of(context).pop(true);
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Dépôt enregistré')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ErrorFormatter.format(e)),
duration: ErrorFormatter.isLcbFtError(e) ? const Duration(seconds: 6) : const Duration(seconds: 3),
),
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Dépôt sur compte épargne'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<_DepotMode>(
segments: const [
ButtonSegment(value: _DepotMode.manual, label: Text('Manuel'), icon: Icon(Icons.edit_note)),
ButtonSegment(value: _DepotMode.wave, label: Text('Wave'), icon: Icon(Icons.phone_android)),
],
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
const SizedBox(height: 16),
TextFormField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant (XOF)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _mode == _DepotMode.manual
? (v) {
if (v == null || v.isEmpty) return 'Obligatoire';
final n = double.tryParse(v.replaceAll(',', '.'));
if (n == null || n <= 0) return 'Montant invalide';
return null;
}
: null,
onChanged: (_) => setState(() {}),
),
if (_mode == _DepotMode.wave) ...[
const SizedBox(height: 16),
TextFormField(
controller: _wavePhoneController,
decoration: const InputDecoration(
labelText: 'Numéro Wave (9 chiffres) *',
hintText: 'Ex: 771234567',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
maxLength: 12,
),
] else ...[
const SizedBox(height: 16),
TextFormField(
controller: _motifController,
decoration: const InputDecoration(
labelText: 'Motif (optionnel)',
border: OutlineInputBorder(),
),
maxLines: 2,
),
const SizedBox(height: 16),
TextFormField(
controller: _origineFondsController,
decoration: InputDecoration(
labelText: 'Origine des fonds (LCB-FT)',
hintText: _origineFondsRequis ? 'Obligatoire au-dessus du seuil' : 'Optionnel',
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
if (_origineFondsRequis)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Requis pour les opérations ≥ ${_seuilLcbFt.toStringAsFixed(0)} XOF',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
if (_origineFondsRequis) ...[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _uploadingDocument ? null : _choisirPieceJustificative,
icon: _uploadingDocument
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
_pieceJustificativeId != null ? Icons.check_circle : Icons.attach_file,
color: _pieceJustificativeId != null ? AppColors.success : null,
),
label: Text(
_pieceJustificativeId != null
? 'Pièce justificative uploadée'
: 'Joindre une pièce justificative *',
),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
side: _pieceJustificativeId != null
? const BorderSide(color: AppColors.success)
: null,
),
),
if (_pieceJustificative != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
_pieceJustificative!.path.split('/').last,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'Photo ou PDF (max 5 MB)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textTertiary,
),
),
),
],
],
],
),
),
),
actions: [
TextButton(
onPressed: (_loading || _waveLoading) ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
if (_mode == _DepotMode.wave)
FilledButton(
onPressed: _waveLoading ? null : _submitWave,
child: _waveLoading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Ouvrir Wave'),
)
else
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Enregistrer'),
),
],
);
}
}