172 lines
4.8 KiB
Dart
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 = 89}) 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;
|
|
}
|