Back to Blog

Handling Complex Financial Group Splits in Flutter with Riverpod and SQLite

Handling Complex Financial Group Splits in Flutter with Riverpod and SQLite Imagine you are building a shared expense app like Splitwise. A group of 5...

Jun 25, 2026
6 min read

Handling Complex Financial Group Splits in Flutter with Riverpod and SQLite

Imagine you are building a shared expense app like Splitwise. A group of 5 friends go on a trip. Throughout the week, they log dozens of expenses: some pay for dinner, some split the car rental by percentages, others borrow custom amounts, and occasionally someone settles up.

To keep the user experience feeling premium and responsive, you need to calculate:

Sponsored Recommendation

Deploy your next full-stack application effortlessly. Get $200 in free DigitalOcean credits to host your Laravel or Python APIs.

  1. The net balance of each group member (who is owed vs who owes).
  2. Simplified debts (the mathematical minimum number of transactions needed to settle all accounts).
  3. Live updates that react instantly as soon as a transaction is logged, even if the device is offline.

Doing these heavy relational calculations on the main UI thread can cause frame drops ("jank") and freeze the UI.

During the development of Paisa Track, we solved this by combining Drift (SQLite) reactive database streams, RxDart, and Riverpod state management. In this post, we will walk you through our exact production architecture.


The Challenge: Reactive Multiplexing

To calculate a group's financial state, we need to monitor four distinct tables in SQLite:

  • Expenses (who paid and how much).
  • GroupSettlements (transfers already made between members).
  • GroupMembers (the people inside the group).
  • ExpenseSplits (the detailed mappings of custom percentages/shares).

If we fetch these tables sequentially, we risk showing stale data. If we recalculate everything on every widget rebuild, the app lags.

We need a way to combine these database streams reactively, run our balance aggregation and debt-simplification algorithms in the background, and expose a clean, computed state to the UI.


1. Combining Streams with RxDart

Instead of listening to four different streams inside our widgets, we use RxDart to combine them into a single reactive pipeline.

Here is the Riverpod provider that listens to our Drift database tables:

final localGroupBalancesProvider = StreamProvider.family<Map<String, dynamic>, String>((ref, groupId) {
  final repo = ref.watch(expenseRepositoryProvider);
  final groupRepo = ref.watch(groupRepositoryProvider);
  final db = ref.watch(databaseProvider);

  // 1. Define individual Drift reactive streams
  final expensesStream = repo.watchExpensesByGroupId(groupId);
  final settlementsStream = (db.select(db.groupSettlements)..where((t) => t.groupId.equals(groupId))).watch();
  final membersStream = groupRepo.watchGroupMembers(groupId);
  final splitsStream = (db.select(db.expenseSplits).join([
    innerJoin(db.expenses, db.expenses.id.equalsExp(db.expenseSplits.expenseId))
  ])..where(db.expenses.groupId.equals(groupId))).watch();
  
  // 2. Combine all streams reactively
  return Rx.combineLatest4<List<Expense>, List<GroupSettlement>, List<GroupMember>, List<TypedResult>, _CombinedData>(
    expensesStream,
    settlementsStream,
    membersStream,
    splitsStream,
    (expenses, settlements, members, splitsTyped) => _CombinedData(expenses, settlements, members, splitsTyped),
  ).asyncMap((data) async {
    // Heavy computation is done here asynchronously
    return calculateGroupBalances(data);
  });
});

Using Rx.combineLatest4, the moment any of the tables change (e.g. a user adds a settlement or edits a split), a single unified event is fired, triggering our calculation engine.


2. The Debt Simplification Algorithm

Once the data is combined, we must calculate the net balances. The core logic follows: $$\text{Net Balance} = \text{Amount Paid} - \text{Amount Owed}$$

If the net balance is positive, the member is a creditor (owed money). If it is negative, they are a debtor (owe money).

Here is how we process the data and solve the debt minimization problem:

Map<String, dynamic> calculateGroupBalances(_CombinedData data) {
  final expenses = data.expenses;
  final settlements = data.settlements;
  final members = data.members;
  final splits = data.splitsTyped.map((row) => row.readTable(db.expenseSplits)).toList();

  final memberBalances = <String, double>{}; // memberId -> net balance

  // 1. Accumulate credits and debts
  for (final exp in expenses) {
    // Payer gets credit
    final payerId = exp.paidByMemberId;
    memberBalances[payerId] = (memberBalances[payerId] ?? 0.0) + exp.amount;
    
    // Splitters get debt
    final expSplits = splits.where((s) => s.expenseId == exp.id).toList();
    if (expSplits.isNotEmpty) {
      for (final s in expSplits) {
        memberBalances[s.groupMemberId] = (memberBalances[s.groupMemberId] ?? 0.0) - s.amount;
      }
    } else {
      // Fallback to Equal Split
      final share = exp.amount / members.length;
      for (final m in members) {
        memberBalances[m.id] = (memberBalances[m.id] ?? 0.0) - share;
      }
    }
  }

  // 2. Adjust balances for settlements already paid
  for (final s in settlements) {
    memberBalances[s.paidByMemberId] = (memberBalances[s.paidByMemberId] ?? 0.0) + s.amount;
    memberBalances[s.paidToMemberId] = (memberBalances[s.paidToMemberId] ?? 0.0) - s.amount;
  }

  // 3. Resolve Debts (Debt Simplification Algorithm)
  final creditors = <_MemberBalance>[];
  final debtors = <_MemberBalance>[];
  
  memberBalances.forEach((mid, bal) {
    if (bal > 0.01) creditors.add(_MemberBalance(mid, bal));
    if (bal < -0.01) debtors.add(_MemberBalance(mid, -bal)); // Keep debt positive for easy matching
  });
  
  // Sort descending to settle largest amounts first
  creditors.sort((a, b) => b.balance.compareTo(a.balance));
  debtors.sort((a, b) => b.balance.compareTo(a.balance));
  
  final suggestions = <Map<String, dynamic>>[];
  int c = 0, d = 0;
  
  while (c < creditors.length && d < debtors.length) {
    final creditor = creditors[c];
    final debtor = debtors[d];
    
    // Settle the minimum of what is owed vs what is due
    final amount = creditor.balance < debtor.balance ? creditor.balance : debtor.balance;
    
    suggestions.add({
      'from_member_id': debtor.uid,
      'to_member_id': creditor.uid,
      'amount': amount,
    });
    
    creditor.balance -= amount;
    debtor.balance -= amount;
    
    if (creditor.balance < 0.01) c++;
    if (debtor.balance < 0.01) d++;
  }

  return {
    'balances': memberBalances,
    'simplified_debts': suggestions,
  };
}

This greedy algorithm simplifies transactions from $O(N^2)$ complex loops down to $O(N \log N)$ matching, ensuring that group members only pay the exact minimum amounts necessary.


3. The Offline-First Hybrid Cache Strategy

To make the app feel incredibly fast, we do not want to fetch calculations from the cloud if the user has no pending uploads.

We built a hybrid provider that acts as a gatekeeper:

final groupBalancesProvider = FutureProvider.family<Map<String, dynamic>, String>((ref, groupId) async {
  final db = ref.read(databaseProvider);
  
  // 1. Check if there are any local unsynced expenses
  final pendingCount = await (db.select(db.expenses)
        ..where((t) => t.groupId.equals(groupId) & t.syncStatus.equals('pending')))
      .get()
      .then((list) => list.length);
  
  final isOnline = ref.read(connectivityProvider);
  
  // 2. If online and local data is clean, fetch exact calculations from API
  if (isOnline && pendingCount == 0) {
    try {
      final dio = ref.read(dioProvider);
      final res = await dio.get('groups/$groupId/balances');
      return Map<String, dynamic>.from(res.data as Map);
    } catch (_) {
      // API failed, fallback to local DB engine
    }
  }
  
  // 3. Fallback to reactive local calculation
  return ref.watch(localGroupBalancesProvider(groupId).future);
});

This architecture ensures that if a user is offline or has pending local offline edits, they get instant, zero-latency calculation locally. Once sync completes, the app switches back to using the backend API as the source of truth, balancing local performance with global synchronization.


Conclusion

Combining multiple streams, running complex business logic in async scopes, and setting up smart caching mechanisms are essential steps in creating robust financial systems. By isolating state calculations using Riverpod and Drift, Paisa Track delivers a zero-latency, lag-free finance environment.

Building a Complex Mobile Solution?

At ScoRpii Tech, we build production-ready mobile systems. Whether you need high-frequency data updates in Flutter, real-time calculations, or specialized API synchronization backends, we have the architectural expertise to construct it.

Try the ScoRpii Tech App Cost Calculator to scope your app's requirements, or Book a Consultation with Our Engineers.

Share this article

What did you think?