Files
ingtrakapp/lib/providers/bank_provider.dart
T
2026-09-10 22:58:52 +02:00

439 lines
14 KiB
Dart

/// [ChangeNotifier] for bank link status, balance, and transactions.
library;
import 'package:flutter/foundation.dart';
import '../models/bank.dart';
import '../services/ing_csv_import.dart';
import '../services/ingtrak_api.dart';
import '../services/storage_service.dart';
import '../utils/constants.dart';
/// App-wide banking state backed by the local ingtrak API + optional CSV import.
class BankController extends ChangeNotifier {
BankController({IngtrakApi? api, this._storage})
: _api = api ?? IngtrakApi() {
final cached = _storage?.loadBankCache();
if (cached != null) {
// Restore last snapshot even if status.linked was lost — avoid empty UI.
_status = cached.status.linked
? cached.status
: ConnectStatus(
status: cached.status.status,
linked: cached.balance != null || cached.transactions.isNotEmpty,
accountId: cached.status.accountId,
sessionId: cached.status.sessionId,
state: cached.status.state,
validUntil: cached.status.validUntil,
);
_balance = cached.balance;
_remoteTransactions = cached.transactions;
}
_importedTransactions = _storage?.loadImportedTransactions() ?? const [];
_invalidateMerged();
}
final IngtrakApi _api;
final StorageService? _storage;
/// Fired after a successful live balance + transactions fetch.
///
/// Used by [SyncController] to reconcile with PocketBase so a bank refresh
/// cannot leave the device behind the cloud (or wipe cloud-only history).
VoidCallback? onAfterSuccessfulLoad;
ConnectStatus _status = ConnectStatus.disconnected;
BankBalance? _balance;
List<BankTransaction> _remoteTransactions = const [];
List<BankTransaction> _importedTransactions = const [];
List<BankTransaction>? _mergedTransactions;
bool _isLoading = false;
bool _isConnecting = false;
bool _isImporting = false;
String? _error;
String? _pendingState;
ConnectStatus get status => _status;
BankBalance? get balance => _balance;
/// Remote API txs merged with locally imported CSV txs (deduped).
///
/// Cached — [mergeTransactions] is O(n·m) and was re-running on every
/// `bank.transactions` read during rebuilds.
List<BankTransaction> get transactions =>
_mergedTransactions ??=
mergeTransactions(_remoteTransactions, _importedTransactions);
void _invalidateMerged() {
_mergedTransactions = null;
}
int get importedCount => _importedTransactions.length;
bool get isLoading => _isLoading;
bool get isConnecting => _isConnecting;
bool get isImporting => _isImporting;
String? get error => _error;
bool get isLinked => _status.linked;
/// True when we have anything useful to show (API cache and/or CSV import).
bool get hasData =>
_balance != null ||
_remoteTransactions.isNotEmpty ||
_importedTransactions.isNotEmpty;
String get apiBaseUrl => _api.baseUrl;
String? get pendingState => _pendingState;
Future<void> _persistCache() async {
final storage = _storage;
if (storage == null) return;
if (_balance == null && _remoteTransactions.isEmpty) {
// Only clear disk when we truly have nothing left to show from the API.
if (!_status.linked) {
await storage.clearBankCache();
}
return;
}
await storage.saveBankCache(
BankCache(
status: ConnectStatus(
status: _status.status,
linked: true,
accountId: _status.accountId,
sessionId: _status.sessionId,
state: _status.state,
validUntil: _status.validUntil,
),
balance: _balance,
transactions: _remoteTransactions,
),
);
}
Future<void> _persistImported() async {
final storage = _storage;
if (storage == null) return;
await storage.saveImportedTransactions(_importedTransactions);
}
/// Import an ING CSV/semicolon export. Skips rows that match existing txs.
Future<CsvImportResult> importIngCsv(String raw) async {
_isImporting = true;
_error = null;
notifyListeners();
try {
final result = importIngCsvAgainst(
raw: raw,
existing: transactions,
);
if (result.added.isNotEmpty) {
_importedTransactions = mergeTransactions(
_importedTransactions,
result.added,
);
_importedTransactions = filterNewTransactions(
_remoteTransactions,
_importedTransactions,
);
_invalidateMerged();
await _persistImported();
}
return result;
} on FormatException catch (error) {
_error = error.message;
rethrow;
} catch (error, stackTrace) {
_error = 'Could not import CSV';
debugPrint('BankController.importIngCsv failed: $error\n$stackTrace');
rethrow;
} finally {
_isImporting = false;
notifyListeners();
}
}
Future<void> clearImportedTransactions() async {
_importedTransactions = const [];
_invalidateMerged();
await _storage?.clearImportedTransactions();
notifyListeners();
}
/// Insert a locally created transaction (admin testing). Persisted like CSV
/// imports and included in PocketBase `transactions` sync.
Future<BankTransaction> addManualTransaction({
required DateTime date,
required String description,
required double amount,
String currency = 'EUR',
bool pending = false,
}) async {
final desc = description.trim().isEmpty ? 'Test transaction' : description.trim();
// Include a short unique suffix so identical drafts do not collide on id.
final stamp = DateTime.now().millisecondsSinceEpoch % 100000;
final tx = BankTransaction(
date:
'${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}',
description: '$desc ·#$stamp',
amount: amount,
currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(),
pending: pending,
);
_importedTransactions = mergeTransactions(_importedTransactions, [tx]);
_importedTransactions = filterNewTransactions(
_remoteTransactions,
_importedTransactions,
);
_invalidateMerged();
await _persistImported();
notifyListeners();
return tx;
}
/// Union cloud txs into the local bank cache without dropping either side.
///
/// Rows land in the API cache; CSV imports that match are deduped away.
Future<void> applySyncedTransactions(
List<BankTransaction> fromCloud, {
bool notify = true,
}) async {
if (fromCloud.isEmpty) return;
_remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud);
_importedTransactions = filterNewTransactions(
_remoteTransactions,
_importedTransactions,
);
_invalidateMerged();
if (!_status.linked && _remoteTransactions.isNotEmpty) {
_status = ConnectStatus(
status: _status.status == 'disconnected' ? 'synced' : _status.status,
linked: true,
accountId: _status.accountId,
sessionId: _status.sessionId,
state: _status.state,
validUntil: _status.validUntil,
);
}
await _persistCache();
await _persistImported();
if (notify) notifyListeners();
}
/// Load status; if linked, also load balance + transactions.
Future<void> refresh() async {
_isLoading = true;
_error = null;
notifyListeners();
var loadedLive = false;
try {
try {
_status = await _api.fetchStatus();
} on IngtrakApiException catch (error) {
// Status often 500 while balance/tx still work — keep going if we
// already have a link (or local data).
if (!isLinked && !hasData) rethrow;
debugPrint(
'BankController.fetchStatus soft-failed (using cache): $error',
);
}
// Prefer loading live data whenever we believe we are linked OR still
// have a local snapshot. Never wipe local txs because status said
// "unlinked" — only a 409 from balance/transactions clears them.
if (_status.linked || hasData) {
loadedLive = await _loadLinkedData();
await _persistCache();
}
} on IngtrakApiException catch (error, stackTrace) {
if (!hasData) {
_error = error.message;
} else {
_error = 'Using saved data · ${error.message}';
}
debugPrint('BankController.refresh failed: $error\n$stackTrace');
} catch (error, stackTrace) {
if (!hasData) {
_error = 'Could not reach $apiBaseUrl';
} else {
_error = 'Using saved data · offline/unreachable API';
}
debugPrint('BankController.refresh failed: $error\n$stackTrace');
} finally {
_isLoading = false;
notifyListeners();
}
if (loadedLive) {
onAfterSuccessfulLoad?.call();
}
}
/// Returns true when balance + transactions were fetched successfully.
Future<bool> _loadLinkedData() async {
try {
final results = await Future.wait([
_api.fetchBalance(),
_api.fetchTransactions(),
]);
_balance = results[0] as BankBalance;
final apiTxs = results[1] as List<BankTransaction>;
// Bank API is ~89 days. Prefer API rows for overlaps, but keep any
// cloud/CSV history that the API window does not include.
_remoteTransactions = mergeTransactions(apiTxs, _remoteTransactions);
if (!_status.linked) {
_status = ConnectStatus(
status: 'linked',
linked: true,
accountId: _status.accountId,
sessionId: _status.sessionId,
state: _status.state,
validUntil: _status.validUntil,
);
}
_importedTransactions = filterNewTransactions(
_remoteTransactions,
_importedTransactions,
);
_invalidateMerged();
await _persistImported();
_error = null;
return true;
} on IngtrakApiException catch (error) {
if (error.isNotLinked) {
// Confirmed unlinked by data endpoints — clear remote only.
_status = ConnectStatus.disconnected;
_balance = null;
_remoteTransactions = const [];
_invalidateMerged();
_error = error.message;
await _persistCache();
} else if (!hasData) {
_error = error.message;
} else {
// Keep previous balance/txs if refresh of data fails.
_error = 'Using saved data · ${error.message}';
}
return false;
}
}
/// `POST /api/connect`. Returns auth payload for the in-app WebView.
Future<ConnectStart?> beginConnect() async {
_isConnecting = true;
_error = null;
notifyListeners();
try {
final start = await _api.startConnect();
if (start.authUrl.isEmpty) {
throw const IngtrakApiException('Connect response missing authUrl');
}
_pendingState = start.state;
return start;
} on IngtrakApiException catch (error, stackTrace) {
_error = error.message;
debugPrint('BankController.beginConnect failed: $error\n$stackTrace');
return null;
} catch (error, stackTrace) {
_error = 'Could not start bank connect.';
debugPrint('BankController.beginConnect failed: $error\n$stackTrace');
return null;
} finally {
_isConnecting = false;
notifyListeners();
}
}
/// Handle optional `ingtrak://connected?...` (e.g. after HTML bounce).
Future<bool> handleRedirect(Uri uri) async {
if (uri.scheme != kBankRedirectScheme) return false;
if (uri.host != kBankRedirectHost && uri.path != '/$kBankRedirectHost') {
if (uri.host.isNotEmpty && uri.host != kBankRedirectHost) return false;
}
final code = uri.queryParameters['code'];
if (code != null && code.isNotEmpty) {
final state = uri.queryParameters['state'] ?? _pendingState;
return completeWithCode(code: code, state: state);
}
if (uri.queryParameters['linked'] == '1') {
await refresh();
if (!isLinked && !hasData) {
_error =
'Returned to the app, but the backend still has no linked session. '
'Finish login inside the in-app WebView (do not open the email '
'link in another browser), then pull to refresh.';
notifyListeners();
}
return isLinked || hasData;
}
final redirectError = uri.queryParameters['error'];
_error = redirectError != null
? 'Bank auth failed: $redirectError'
: 'Bank redirect missing code';
notifyListeners();
return true;
}
Future<bool> completeWithCode({
required String code,
String? state,
}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_status = await _api.completeConnect(
code: code,
state: state ?? _pendingState,
);
_pendingState = null;
var loadedLive = false;
if (_status.linked) {
loadedLive = await _loadLinkedData();
await _persistCache();
}
final linked = _status.linked;
// Notify before cloud reconcile so UI can leave the connecting state.
_isLoading = false;
notifyListeners();
if (loadedLive) {
onAfterSuccessfulLoad?.call();
}
return linked;
} on IngtrakApiException catch (error, stackTrace) {
_error = error.message;
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
return false;
} catch (error, stackTrace) {
_error = 'Could not complete bank connect.';
debugPrint('BankController.completeWithCode failed: $error\n$stackTrace');
return false;
} finally {
if (_isLoading) {
_isLoading = false;
notifyListeners();
}
}
}
@override
void dispose() {
onAfterSuccessfulLoad = null;
_api.close();
super.dispose();
}
}