/// Thin HTTP client for the personal ingtrak FastAPI backend. library; import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/bank.dart'; import '../utils/constants.dart'; /// Talks to Enable Banking via the local ingtrak API (no app-user auth). class IngtrakApi { IngtrakApi({http.Client? client, this.baseUrl = kApiBaseUrl}) : _client = client ?? http.Client(); final http.Client _client; final String baseUrl; Uri _uri(String path, [Map? query]) { final normalized = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; return Uri.parse('$normalized$path').replace(queryParameters: query); } Future> _decodeObject(http.Response response) { final decoded = jsonDecode(response.body); if (decoded is! Map) { throw const IngtrakApiException('Unexpected response shape'); } return Future.value(decoded); } void _ensureOk(http.Response response) { if (response.statusCode >= 200 && response.statusCode < 300) return; throw IngtrakApiException( _messageFromBody(response) ?? 'Request failed (${response.statusCode})', statusCode: response.statusCode, ); } String? _messageFromBody(http.Response response) { try { final decoded = jsonDecode(response.body); if (decoded is Map) { final detail = decoded['detail']; if (detail is String) return detail; if (detail is Map) { final nested = detail['enablebanking']; if (nested is Map && nested['message'] is String) { return nested['message'] as String; } if (detail['message'] is String) { return detail['message'] as String; } } } } catch (_) { // Fall through to generic message. } return null; } Future health() async { final response = await _client.get( _uri('/health'), headers: const {'Accept': 'application/json'}, ); _ensureOk(response); final body = await _decodeObject(response); return body['status'] == 'ok'; } /// `POST /api/connect` → open [ConnectStart.authUrl] in a browser. Future startConnect({ String? aspspName, String? aspspCountry, }) async { final payload = {}; if (aspspName != null && aspspName.isNotEmpty) { payload['aspspName'] = aspspName; } if (aspspCountry != null && aspspCountry.isNotEmpty) { payload['aspspCountry'] = aspspCountry; } final response = await _client.post( _uri('/api/connect'), headers: const { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: jsonEncode(payload), ); _ensureOk(response); return ConnectStart.fromJson(await _decodeObject(response)); } /// `POST /api/connect/complete` after the deep-link redirect. Future completeConnect({ required String code, String? state, }) async { final payload = {'code': code}; if (state != null && state.isNotEmpty) { payload['state'] = state; } final response = await _client.post( _uri('/api/connect/complete'), headers: const { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: jsonEncode(payload), ); _ensureOk(response); return ConnectStatus.fromJson(await _decodeObject(response)); } Future fetchStatus() async { final response = await _client.get( _uri('/api/connect/status'), headers: const {'Accept': 'application/json'}, ); _ensureOk(response); return ConnectStatus.fromJson(await _decodeObject(response)); } Future fetchBalance() async { final response = await _client.get( _uri('/api/balance'), headers: const {'Accept': 'application/json'}, ); _ensureOk(response); return BankBalance.fromJson(await _decodeObject(response)); } Future> fetchTransactions({int days = 90}) async { final response = await _client.get( _uri('/api/transactions', {'days': '$days'}), headers: const {'Accept': 'application/json'}, ); _ensureOk(response); final decoded = jsonDecode(response.body); if (decoded is! List) { throw const IngtrakApiException('Unexpected transactions response'); } return decoded .whereType>() .map(BankTransaction.fromJson) .toList(growable: false); } void close() => _client.close(); } class IngtrakApiException implements Exception { const IngtrakApiException(this.message, {this.statusCode}); final String message; final int? statusCode; bool get isNotLinked => statusCode == 409; @override String toString() => message; }