Files
TravelMate/lib/services/balance_service.dart

315 lines
10 KiB
Dart

/// Service responsible for calculating and managing financial balances within travel groups.
///
/// This service handles:
/// - Group balance calculations
/// - Individual user balance tracking
/// - Optimal settlement calculations (debt optimization)
/// - Real-time balance streaming
/// - Group spending statistics and analytics
///
/// The service uses a greedy algorithm to optimize settlements between users,
/// minimizing the number of transactions needed to settle all debts.
///
/// Example usage:
/// ```dart
/// final balanceService = BalanceService(
/// balanceRepository: balanceRepository,
/// expenseRepository: expenseRepository,
/// );
///
/// // Get real-time balance updates
/// balanceService.getGroupBalanceStream(groupId).listen((balance) {
/// print('Total expenses: ${balance.totalExpenses}');
/// print('Settlements needed: ${balance.settlements.length}');
/// });
///
/// // Calculate optimal settlements
/// final settlements = await balanceService.calculateOptimalSettlements(groupId);
/// ```
///
/// See also:
/// - [GroupBalance] for the complete balance structure
/// - [Settlement] for individual payment recommendations
/// - [UserBalance] for per-user balance information
library;
import '../models/group_balance.dart';
import '../models/expense.dart';
import '../models/group_statistics.dart';
import '../models/user_balance.dart';
import '../models/settlement.dart';
import '../repositories/balance_repository.dart';
import '../repositories/expense_repository.dart';
import 'error_service.dart';
class BalanceService {
final BalanceRepository _balanceRepository;
final ExpenseRepository _expenseRepository;
final ErrorService _errorService;
BalanceService({
required BalanceRepository balanceRepository,
required ExpenseRepository expenseRepository,
ErrorService? errorService,
}) : _balanceRepository = balanceRepository,
_expenseRepository = expenseRepository,
_errorService = errorService ?? ErrorService();
/// Calculer la balance complète d'un groupe
Future<GroupBalance> calculateGroupBalance(String groupId) async {
try {
return await _balanceRepository.calculateGroupBalance(groupId);
} catch (e) {
_errorService.logError('BalanceService', 'Erreur calcul balance groupe: $e');
rethrow;
}
}
Future<List<Settlement>> calculateOptimalSettlements(String groupId) async {
try {
final expenses = await _expenseRepository
.getExpensesStream(groupId)
.first;
final userBalances = calculateUserBalances(expenses);
return optimizeSettlements(userBalances);
} catch (e) {
_errorService.logError('BalanceService', 'Erreur calcul settlements: $e');
rethrow;
}
}
/// Stream de la balance en temps réel
Stream<GroupBalance> getGroupBalanceStream(String groupId) {
return _expenseRepository.getExpensesStream(groupId).asyncMap((expenses) async {
try {
final userBalances = calculateUserBalances(expenses);
final settlements = optimizeSettlements(userBalances);
final totalExpenses = expenses
.where((e) => !e.isArchived)
.fold(0.0, (sum, e) => sum + e.amountInEur);
return GroupBalance(
groupId: groupId,
userBalances: userBalances,
settlements: settlements,
totalExpenses: totalExpenses,
calculatedAt: DateTime.now(),
);
} catch (e) {
_errorService.logError('BalanceService', 'Erreur stream balance: $e');
rethrow;
}
});
}
/// Calculer les balances individuelles (logique métier)
List<UserBalance> calculateUserBalances(List<Expense> expenses) {
final Map<String, _UserBalanceCalculator> calculators = {};
// Initialiser les calculateurs pour chaque utilisateur
for (final expense in expenses) {
if (expense.isArchived) continue;
// Ajouter le payeur
calculators.putIfAbsent(
expense.paidById,
() => _UserBalanceCalculator(expense.paidById, expense.paidByName),
);
// Ajouter les participants
for (final split in expense.splits) {
calculators.putIfAbsent(
split.userId,
() => _UserBalanceCalculator(split.userId, split.userName),
);
}
}
// Calculer les montants
for (final expense in expenses) {
if (expense.isArchived) continue;
// Ajouter au montant payé
calculators[expense.paidById]!.addPaid(expense.amountInEur);
// Ajouter aux montants dus
for (final split in expense.splits) {
calculators[split.userId]!.addOwed(split.amount);
}
}
// Convertir en UserBalance
return calculators.values.map((calc) => calc.toUserBalance()).toList();
}
/// Optimiser les règlements (algorithme avancé)
List<Settlement> optimizeSettlements(List<UserBalance> balances) {
final settlements = <Settlement>[];
// Filtrer les utilisateurs avec une balance significative (> 0.01€)
final creditors = balances
.where((b) => b.shouldReceive && b.balance > 0.01)
.toList();
final debtors = balances
.where((b) => b.shouldPay && b.balance < -0.01)
.toList();
if (creditors.isEmpty || debtors.isEmpty) {
return settlements;
}
// Trier par montant absolu décroissant pour optimiser
creditors.sort((a, b) => b.balance.compareTo(a.balance));
debtors.sort((a, b) => a.balance.compareTo(b.balance));
// Utiliser des copies mutables pour les calculs
final creditorsRemaining = Map<String, double>.fromEntries(
creditors.map((c) => MapEntry(c.userId, c.balance))
);
final debtorsRemaining = Map<String, double>.fromEntries(
debtors.map((d) => MapEntry(d.userId, -d.balance))
);
// Algorithme glouton optimisé
for (final creditor in creditors) {
final creditRemaining = creditorsRemaining[creditor.userId] ?? 0;
if (creditRemaining <= 0.01) continue;
for (final debtor in debtors) {
final debtRemaining = debtorsRemaining[debtor.userId] ?? 0;
if (debtRemaining <= 0.01) continue;
final settlementAmount = _calculateOptimalSettlementAmount(
creditRemaining,
debtRemaining,
);
if (settlementAmount > 0.01) {
settlements.add(Settlement(
fromUserId: debtor.userId,
fromUserName: debtor.userName,
toUserId: creditor.userId,
toUserName: creditor.userName,
amount: settlementAmount,
));
// Mettre à jour les montants restants
creditorsRemaining[creditor.userId] = creditRemaining - settlementAmount;
debtorsRemaining[debtor.userId] = debtRemaining - settlementAmount;
}
}
}
return _validateSettlements(settlements);
}
/// Calculer le montant optimal pour un règlement
double _calculateOptimalSettlementAmount(double creditAmount, double debtAmount) {
final amount = [creditAmount, debtAmount].reduce((a, b) => a < b ? a : b);
// Arrondir à 2 décimales
return (amount * 100).round() / 100;
}
/// Valider les règlements calculés
List<Settlement> _validateSettlements(List<Settlement> settlements) {
// Supprimer les règlements trop petits
final validSettlements = settlements
.where((s) => s.amount > 0.01)
.toList();
// Log pour debug en cas de problème
final totalSettlements = validSettlements.fold(0.0, (sum, s) => sum + s.amount);
_errorService.logInfo('BalanceService',
'Règlements calculés: ${validSettlements.length}, Total: ${totalSettlements.toStringAsFixed(2)}');
return validSettlements;
}
/// Calculer la dette entre deux utilisateurs spécifiques
double calculateDebtBetweenUsers(String groupId, String userId1, String userId2) {
// Cette méthode pourrait être utile pour des fonctionnalités avancées
// comme "Combien me doit X ?" ou "Combien je dois à Y ?"
return 0.0; // TODO: Implémenter si nécessaire
}
/// Analyser les tendances de dépenses par catégorie
Map<String, double> analyzeCategorySpending(List<Expense> expenses) {
final categoryTotals = <String, double>{};
for (final expense in expenses) {
if (expense.isArchived) continue;
final categoryName = expense.category.displayName;
categoryTotals[categoryName] = (categoryTotals[categoryName] ?? 0) + expense.amountInEur;
}
return categoryTotals;
}
/// Calculer les statistiques du groupe
GroupStatistics calculateGroupStatistics(List<Expense> expenses) {
if (expenses.isEmpty) {
return GroupStatistics.empty();
}
final nonArchivedExpenses = expenses.where((e) => !e.isArchived).toList();
final totalAmount = nonArchivedExpenses.fold(0.0, (sum, e) => sum + e.amountInEur);
final averageAmount = totalAmount / nonArchivedExpenses.length;
final categorySpending = analyzeCategorySpending(nonArchivedExpenses);
final topCategory = categorySpending.entries
.reduce((a, b) => a.value > b.value ? a : b)
.key;
return GroupStatistics(
totalExpenses: totalAmount,
expenseCount: nonArchivedExpenses.length,
averageExpense: averageAmount,
topCategory: topCategory,
categoryBreakdown: categorySpending,
);
}
Future<void> markSettlementAsCompleted({
required String groupId,
required String fromUserId,
required String toUserId,
required double amount,
}) async {
try {
// Ici vous pourriez enregistrer le règlement en base
// ou créer une transaction de règlement
// Pour l'instant, on pourrait juste recalculer
await Future.delayed(const Duration(milliseconds: 100));
_errorService.logSuccess('BalanceService', 'Règlement marqué comme effectué');
} catch (e) {
_errorService.logError('BalanceService', 'Erreur mark settlement: $e');
rethrow;
}
}
}
/// Classe helper pour calculer les balances
class _UserBalanceCalculator {
final String userId;
final String userName;
double _totalPaid = 0.0;
double _totalOwed = 0.0;
_UserBalanceCalculator(this.userId, this.userName);
void addPaid(double amount) => _totalPaid += amount;
void addOwed(double amount) => _totalOwed += amount;
UserBalance toUserBalance() {
return UserBalance(
userId: userId,
userName: userName,
totalPaid: _totalPaid,
totalOwed: _totalOwed,
balance: _totalPaid - _totalOwed,
);
}
}