Files
ingtrakapp/lib/services/ingtrak_api.dart
T
2026-09-06 21:13:30 +02:00

172 lines
4.8 KiB
Dart

/// 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<String, String>? query]) {
final normalized = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
return Uri.parse('$normalized$path').replace(queryParameters: query);
}
Future<Map<String, dynamic>> _decodeObject(http.Response response) {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
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<String, dynamic>) {
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<bool> 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<ConnectStart> startConnect({
String? aspspName,
String? aspspCountry,
}) async {
final payload = <String, String>{};
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<ConnectStatus> completeConnect({
required String code,
String? state,
}) async {
final payload = <String, String>{'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<ConnectStatus> 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<BankBalance> fetchBalance() async {
final response = await _client.get(
_uri('/api/balance'),
headers: const {'Accept': 'application/json'},
);
_ensureOk(response);
return BankBalance.fromJson(await _decodeObject(response));
}
Future<List<BankTransaction>> 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<String, dynamic>>()
.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;
}