diff --git a/frontend/pshared/lib/api/responses/ledger/accounts.dart b/frontend/pshared/lib/api/responses/ledger/accounts.dart new file mode 100644 index 00000000..ef74455e --- /dev/null +++ b/frontend/pshared/lib/api/responses/ledger/accounts.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/ledger/account.dart'; + +part 'accounts.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class LedgerAccountsResponse { + final List accounts; + + const LedgerAccountsResponse({required this.accounts}); + + factory LedgerAccountsResponse.fromJson(Map json) => _$LedgerAccountsResponseFromJson(json); + Map toJson() => _$LedgerAccountsResponseToJson(this); +} diff --git a/frontend/pshared/lib/api/responses/ledger/balance.dart b/frontend/pshared/lib/api/responses/ledger/balance.dart new file mode 100644 index 00000000..0feb3d97 --- /dev/null +++ b/frontend/pshared/lib/api/responses/ledger/balance.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/ledger/balance.dart'; + +part 'balance.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class LedgerBalanceResponse { + final LedgerBalanceDTO balance; + + const LedgerBalanceResponse({required this.balance}); + + factory LedgerBalanceResponse.fromJson(Map json) => _$LedgerBalanceResponseFromJson(json); + Map toJson() => _$LedgerBalanceResponseToJson(this); +} diff --git a/frontend/pshared/lib/controllers/wallets.dart b/frontend/pshared/lib/controllers/wallets.dart index daca2180..09bee327 100644 --- a/frontend/pshared/lib/controllers/wallets.dart +++ b/frontend/pshared/lib/controllers/wallets.dart @@ -22,7 +22,7 @@ class WalletsController with ChangeNotifier { void update(WalletsProvider wallets) { _wallets = wallets; - final nextOrgRef = wallets.organizationId; + final nextOrgRef = wallets.organizationRef; final orgChanged = nextOrgRef != _orgRef; if (orgChanged) { diff --git a/frontend/pshared/lib/data/mapper/ledger/account.dart b/frontend/pshared/lib/data/mapper/ledger/account.dart index 464d1217..0a7c17e7 100644 --- a/frontend/pshared/lib/data/mapper/ledger/account.dart +++ b/frontend/pshared/lib/data/mapper/ledger/account.dart @@ -4,8 +4,8 @@ import 'package:pshared/data/mapper/ledger/balance.dart'; import 'package:pshared/models/ledger/account.dart'; -extension LedgerAccountDtoMapper on LedgerAccountDTO { - LedgerAccount toModel() => LedgerAccount( +extension LedgerAccountDTOMapper on LedgerAccountDTO { + LedgerAccount toDomain() => LedgerAccount( ledgerAccountRef: ledgerAccountRef, organizationRef: organizationRef, ownerRef: ownerRef, diff --git a/frontend/pshared/lib/models/ledger/account.dart b/frontend/pshared/lib/models/ledger/account.dart index f2dabb1a..d569104f 100644 --- a/frontend/pshared/lib/models/ledger/account.dart +++ b/frontend/pshared/lib/models/ledger/account.dart @@ -43,6 +43,7 @@ class LedgerAccount implements Describable { LedgerAccount copyWith({ Describable? describable, + LedgerBalance? balance, }) => LedgerAccount( ledgerAccountRef: ledgerAccountRef, organizationRef: organizationRef, @@ -57,6 +58,6 @@ class LedgerAccount implements Describable { createdAt: createdAt, updatedAt: updatedAt, describable: describable ?? this.describable, - balance: balance, + balance: balance ?? this.balance, ); } diff --git a/frontend/pshared/lib/provider/ledger.dart b/frontend/pshared/lib/provider/ledger.dart new file mode 100644 index 00000000..d5359fdc --- /dev/null +++ b/frontend/pshared/lib/provider/ledger.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import 'package:collection/collection.dart'; +import 'package:pshared/models/ledger/account.dart'; + +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/ledger.dart'; +import 'package:pshared/utils/exception.dart'; + + +class LedgerAccountsProvider with ChangeNotifier { + final LedgerService _service; + OrganizationsProvider? _organizations; + + LedgerAccountsProvider(this._service); + + Resource> _resource = Resource(data: []); + Resource> get resource => _resource; + + List get accounts => _resource.data ?? []; + bool get isLoading => _resource.isLoading; + Exception? get error => _resource.error; + + bool _isRefreshingBalances = false; + bool get isRefreshingBalances => _isRefreshingBalances; + + final Set _refreshingAccounts = {}; + bool isWalletRefreshing(String ledgerAccountRef) => _refreshingAccounts.contains(ledgerAccountRef); + + // Expose current org id so UI controller can reset per-org state if needed. + String? get organizationRef => + (_organizations?.isOrganizationSet ?? false) ? _organizations!.current.id : null; + + // Used to ignore stale async results (org changes / overlapping requests). + int _opSeq = 0; + + // Per-wallet refresh sequence guard. + final Map _accountSeq = {}; + + // Keep modest concurrency to avoid hammering the backend. + static const int _balanceConcurrency = 6; + + void update(OrganizationsProvider organizations) { + _organizations = organizations; + if (organizations.isOrganizationSet) { + unawaited(loadAccountsWithBalances()); + } + } + + Future updateLedgerAccount(LedgerAccount ledgerAccount) { + throw Exception('update ledger account is not implemented'); + } + + Future loadAccountsWithBalances() async { + final org = _organizations; + if (org == null || !org.isOrganizationSet) return; + + final orgRef = org.current.id; + final seq = ++_opSeq; + + _isRefreshingBalances = false; + _refreshingAccounts.clear(); + + _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + + try { + final base = await _service.list(orgRef); + + final result = await _withBalances(orgRef, base); + if (seq != _opSeq) return; + + _applyResource( + Resource>(data: result.wallets, isLoading: false, error: result.error), + notify: true, + ); + } catch (e) { + if (seq != _opSeq) return; + + _applyResource( + _resource.copyWith(isLoading: false, error: toException(e)), + notify: true, + ); + } + } + + Future refreshBalances() async { + final org = _organizations; + if (org == null || !org.isOrganizationSet) return; + if (accounts.isEmpty) return; + + final orgId = org.current.id; + final seq = ++_opSeq; + + _isRefreshingBalances = true; + _applyResource(_resource.copyWith(error: null), notify: false); + notifyListeners(); + + try { + final result = await _withBalances(orgId, accounts); + if (seq != _opSeq) return; + + _applyResource( + _resource.copyWith(data: result.wallets, error: result.error), + notify: false, + ); + } catch (e) { + if (seq != _opSeq) return; + + _applyResource(_resource.copyWith(error: toException(e)), notify: false); + } finally { + if (seq == _opSeq) { + _isRefreshingBalances = false; + notifyListeners(); + } + } + } + + Future refreshBalance(String ledgerAccountRef) async { + final org = _organizations; + if (org == null || !org.isOrganizationSet) return; + + if (_refreshingAccounts.contains(ledgerAccountRef)) return; + + final existing = accounts.firstWhereOrNull((w) => w.ledgerAccountRef == ledgerAccountRef); + if (existing == null) return; + + final orgRef = org.current.id; + final seq = (_accountSeq[ledgerAccountRef] ?? 0) + 1; + _accountSeq[ledgerAccountRef] = seq; + + _refreshingAccounts.add(ledgerAccountRef); + notifyListeners(); + + try { + final balance = await _service.getBalance( + organizationRef: orgRef, + ledgerAccountRef: ledgerAccountRef, + ); + if ((_accountSeq[ledgerAccountRef] ?? 0) != seq) return; + + final next = _replaceWallet(ledgerAccountRef, (w) => w.copyWith(balance: balance)); + if (next == null) return; + + _applyResource(_resource.copyWith(data: next), notify: false); + } catch (e) { + if ((_accountSeq[ledgerAccountRef] ?? 0) != seq) return; + + _applyResource(_resource.copyWith(error: toException(e)), notify: false); + } finally { + if ((_accountSeq[ledgerAccountRef] ?? 0) == seq) { + _refreshingAccounts.remove(ledgerAccountRef); + notifyListeners(); + } + } + } + + // ---------- internals ---------- + + void _applyResource(Resource> newResource, {required bool notify}) { + _resource = newResource; + if (notify) notifyListeners(); + } + + List? _replaceWallet(String ledgerAccountRef, LedgerAccount Function(LedgerAccount) updater) { + final idx = accounts.indexWhere((w) => w.ledgerAccountRef == ledgerAccountRef); + if (idx < 0) return null; + + final next = List.from(accounts); + next[idx] = updater(next[idx]); + return next; + } + + Future<_LedgerAccountLoadResult> _withBalances(String orgRef, List base) async { + Exception? firstError; + + final withBalances = await _mapConcurrent( + base, + _balanceConcurrency, + (ledgerAccount) async { + try { + final balance = await _service.getBalance( + organizationRef: orgRef, + ledgerAccountRef: ledgerAccount.ledgerAccountRef, + ); + return ledgerAccount.copyWith(balance: balance); + } catch (e) { + firstError ??= toException(e); + return ledgerAccount; + } + }, + ); + + return _LedgerAccountLoadResult(withBalances, firstError); + } + + static Future> _mapConcurrent( + List items, + int concurrency, + Future Function(T) fn, + ) async { + if (items.isEmpty) return []; + + final results = List.filled(items.length, null); + var nextIndex = 0; + + Future worker() async { + while (true) { + final i = nextIndex++; + if (i >= items.length) return; + results[i] = await fn(items[i]); + } + } + + final workers = List.generate(min(concurrency, items.length), (_) => worker()); + await Future.wait(workers); + + return results.cast(); + } +} + +class _LedgerAccountLoadResult { + final List wallets; + final Exception? error; + + const _LedgerAccountLoadResult(this.wallets, this.error); +} diff --git a/frontend/pshared/lib/provider/payment/wallets.dart b/frontend/pshared/lib/provider/payment/wallets.dart index 03c7b796..b3c75c9f 100644 --- a/frontend/pshared/lib/provider/payment/wallets.dart +++ b/frontend/pshared/lib/provider/payment/wallets.dart @@ -29,10 +29,10 @@ class WalletsProvider with ChangeNotifier { bool get isRefreshingBalances => _isRefreshingBalances; final Set _refreshingWallets = {}; - bool isWalletRefreshing(String walletId) => _refreshingWallets.contains(walletId); + bool isWalletRefreshing(String walletRef) => _refreshingWallets.contains(walletRef); // Expose current org id so UI controller can reset per-org state if needed. - String? get organizationId => + String? get organizationRef => (_organizations?.isOrganizationSet ?? false) ? _organizations!.current.id : null; // Used to ignore stale async results (org changes / overlapping requests). @@ -96,7 +96,7 @@ class WalletsProvider with ChangeNotifier { if (org == null || !org.isOrganizationSet) return; if (wallets.isEmpty) return; - final orgId = org.current.id; + final orgRef = org.current.id; final seq = ++_opSeq; _isRefreshingBalances = true; @@ -104,7 +104,7 @@ class WalletsProvider with ChangeNotifier { notifyListeners(); try { - final result = await _withBalances(orgId, wallets); + final result = await _withBalances(orgRef, wallets); if (seq != _opSeq) return; _applyResource( @@ -123,37 +123,37 @@ class WalletsProvider with ChangeNotifier { } } - Future refreshBalance(String walletId) async { + Future refreshBalance(String walletRef) async { final org = _organizations; if (org == null || !org.isOrganizationSet) return; - if (_refreshingWallets.contains(walletId)) return; + if (_refreshingWallets.contains(walletRef)) return; - final existing = wallets.firstWhereOrNull((w) => w.id == walletId); + final existing = wallets.firstWhereOrNull((w) => w.id == walletRef); if (existing == null) return; final orgId = org.current.id; - final seq = (_walletSeq[walletId] ?? 0) + 1; - _walletSeq[walletId] = seq; + final seq = (_walletSeq[walletRef] ?? 0) + 1; + _walletSeq[walletRef] = seq; - _refreshingWallets.add(walletId); + _refreshingWallets.add(walletRef); notifyListeners(); try { - final balance = await _service.getBalance(orgId, walletId); - if ((_walletSeq[walletId] ?? 0) != seq) return; + final balance = await _service.getBalance(orgId, walletRef); + if ((_walletSeq[walletRef] ?? 0) != seq) return; - final next = _replaceWallet(walletId, (w) => w.copyWith(balance: balance)); + final next = _replaceWallet(walletRef, (w) => w.copyWith(balance: balance)); if (next == null) return; _applyResource(_resource.copyWith(data: next), notify: false); } catch (e) { - if ((_walletSeq[walletId] ?? 0) != seq) return; + if ((_walletSeq[walletRef] ?? 0) != seq) return; _applyResource(_resource.copyWith(error: toException(e)), notify: false); } finally { - if ((_walletSeq[walletId] ?? 0) == seq) { - _refreshingWallets.remove(walletId); + if ((_walletSeq[walletRef] ?? 0) == seq) { + _refreshingWallets.remove(walletRef); notifyListeners(); } } @@ -166,8 +166,8 @@ class WalletsProvider with ChangeNotifier { if (notify) notifyListeners(); } - List? _replaceWallet(String walletId, Wallet Function(Wallet) updater) { - final idx = wallets.indexWhere((w) => w.id == walletId); + List? _replaceWallet(String walletRef, Wallet Function(Wallet) updater) { + final idx = wallets.indexWhere((w) => w.id == walletRef); if (idx < 0) return null; final next = List.from(wallets); @@ -175,7 +175,7 @@ class WalletsProvider with ChangeNotifier { return next; } - Future<_WalletLoadResult> _withBalances(String orgId, List base) async { + Future<_WalletLoadResult> _withBalances(String orgRef, List base) async { Exception? firstError; final withBalances = await _mapConcurrent( @@ -183,7 +183,7 @@ class WalletsProvider with ChangeNotifier { _balanceConcurrency, (wallet) async { try { - final balance = await _service.getBalance(orgId, wallet.id); + final balance = await _service.getBalance(orgRef, wallet.id); return wallet.copyWith(balance: balance); } catch (e) { firstError ??= toException(e); diff --git a/frontend/pshared/lib/service/ledger.dart b/frontend/pshared/lib/service/ledger.dart index 3de575d3..6b0e5ea5 100644 --- a/frontend/pshared/lib/service/ledger.dart +++ b/frontend/pshared/lib/service/ledger.dart @@ -1,8 +1,9 @@ -import 'package:pshared/api/responses/wallet_balance.dart'; -import 'package:pshared/api/responses/wallets.dart'; -import 'package:pshared/data/mapper/wallet/response.dart'; -import 'package:pshared/models/wallet/balance.dart'; -import 'package:pshared/models/wallet/wallet.dart'; +import 'package:pshared/api/responses/ledger/accounts.dart'; +import 'package:pshared/api/responses/ledger/balance.dart'; +import 'package:pshared/data/mapper/ledger/account.dart'; +import 'package:pshared/data/mapper/ledger/balance.dart'; +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/ledger/balance.dart'; import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; @@ -10,22 +11,22 @@ import 'package:pshared/service/services.dart'; class LedgerService { static const String _objectType = Services.ledger; - static Future> list(String organizationRef) async { + Future> list(String organizationRef) async { final json = await AuthorizationService.getGETResponse( _objectType, '/$organizationRef', ); - return WalletsResponse.fromJson(json).toDomain(); + return LedgerAccountsResponse.fromJson(json).accounts.map((la) => la.toDomain()).toList(); } - static Future getBalance({ + Future getBalance({ required String organizationRef, - required String walletRef, + required String ledgerAccountRef, }) async { final json = await AuthorizationService.getGETResponse( _objectType, - '/$organizationRef/$walletRef/balance', + '/$organizationRef/$ledgerAccountRef/balance', ); - return WalletBalanceResponse.fromJson(json).toDomain(); + return LedgerBalanceResponse.fromJson(json).balance.toDomain(); } }