Line data Source code
1 : // Copyright 2026 The terCAD team. All rights reserved.
2 : // Use of this source code is governed by a CC BY-NC-ND 4.0 license that can be found in the LICENSE file.
3 :
4 : import 'dart:convert';
5 :
6 : import 'package:app_finance/_classes/herald/app_locale.dart';
7 : import 'package:encrypt/encrypt.dart';
8 : import 'package:http/http.dart' as http;
9 : import 'package:pointycastle/asymmetric/api.dart';
10 :
11 : // Enable Banking API (https://enablebanking.com/docs/api/reference/)
12 : class EnableBankingException implements Exception {
13 : final String message;
14 : final int? statusCode;
15 :
16 1 : const EnableBankingException(this.message, [this.statusCode]);
17 :
18 0 : @override
19 0 : String toString() => message;
20 : }
21 :
22 : class EnableBankingAspsp {
23 : final String name;
24 : final String country;
25 : final Duration? maximumConsentValidity;
26 :
27 1 : const EnableBankingAspsp({required this.name, required this.country, this.maximumConsentValidity});
28 :
29 2 : factory EnableBankingAspsp.fromJson(Map<String, dynamic> json) => EnableBankingAspsp(
30 2 : name: json['name'].toString(),
31 2 : country: json['country'].toString(),
32 2 : maximumConsentValidity: json['maximum_consent_validity'] is num
33 3 : ? Duration(seconds: (json['maximum_consent_validity'] as num).toInt())
34 : : null,
35 : );
36 : }
37 :
38 : class EnableBankingAccount {
39 : final String uid;
40 : final String? iban;
41 : final String name;
42 : final String? currency;
43 :
44 1 : const EnableBankingAccount({required this.uid, required this.name, this.iban, this.currency});
45 :
46 4 : String get key => iban?.replaceAll(' ', '').toUpperCase() ?? uid;
47 :
48 1 : factory EnableBankingAccount.fromJson(Map<String, dynamic> json) {
49 1 : final accountId = json['account_id'];
50 1 : return EnableBankingAccount(
51 2 : uid: json['uid'].toString(),
52 3 : iban: accountId is Map ? accountId['iban']?.toString() : null,
53 3 : name: (json['name'] ?? json['product'] ?? json['details'] ?? '').toString().trim(),
54 2 : currency: json['currency']?.toString(),
55 : );
56 : }
57 :
58 2 : Map<String, dynamic> toJson() => {
59 1 : 'uid': uid,
60 2 : 'account_id': {'iban': iban},
61 1 : 'name': name,
62 1 : 'currency': currency,
63 : };
64 : }
65 :
66 : class EnableBankingBalance {
67 : final double amount;
68 : final String currency;
69 : final String? type;
70 :
71 0 : const EnableBankingBalance({required this.amount, required this.currency, this.type});
72 :
73 0 : factory EnableBankingBalance.fromJson(Map<String, dynamic> json) {
74 0 : final value = json['balance_amount'] as Map<String, dynamic>? ?? {};
75 0 : return EnableBankingBalance(
76 0 : amount: EnableBankingClient.parseAmount(value['amount']),
77 0 : currency: (value['currency'] ?? '').toString(),
78 0 : type: json['balance_type']?.toString(),
79 : );
80 : }
81 : }
82 :
83 : class EnableBankingTransaction {
84 : final String? reference;
85 : final double amount;
86 : final String currency;
87 : final String? status;
88 : final DateTime? date;
89 : final String title;
90 :
91 1 : const EnableBankingTransaction({
92 : required this.amount,
93 : required this.currency,
94 : required this.title,
95 : this.reference,
96 : this.status,
97 : this.date,
98 : });
99 :
100 4 : bool get isBooked => status == null || status == 'BOOK';
101 :
102 1 : factory EnableBankingTransaction.fromJson(Map<String, dynamic> json) {
103 1 : final value = json['transaction_amount'] as Map<String, dynamic>? ?? {};
104 2 : final isDebit = json['credit_debit_indicator'] == 'DBIT';
105 3 : final amount = EnableBankingClient.parseAmount(value['amount']).abs();
106 2 : final party = (isDebit ? json['creditor'] : json['debtor']) as Map<String, dynamic>?;
107 7 : final remittance = (json['remittance_information'] as List? ?? []).map((e) => e.toString().trim()).join(' ');
108 5 : final title = [party?['name']?.toString().trim() ?? '', remittance, json['note']?.toString().trim() ?? '']
109 3 : .where((e) => e.isNotEmpty)
110 1 : .take(2)
111 1 : .join(' — ');
112 1 : return EnableBankingTransaction(
113 3 : reference: (json['entry_reference'] ?? json['transaction_id'])?.toString(),
114 1 : amount: isDebit ? -amount : amount,
115 2 : currency: (value['currency'] ?? '').toString(),
116 2 : status: json['status']?.toString(),
117 3 : date: EnableBankingClient.parseDate(json['booking_date'] ?? json['value_date'] ?? json['transaction_date']),
118 : title: title,
119 : );
120 : }
121 : }
122 :
123 : class EnableBankingSession {
124 : final String id;
125 : final DateTime? validUntil;
126 : final List<EnableBankingAccount> accounts;
127 :
128 1 : const EnableBankingSession({required this.id, required this.accounts, this.validUntil});
129 :
130 5 : bool get isValid => validUntil == null || validUntil!.isAfter(DateTime.now());
131 :
132 1 : factory EnableBankingSession.fromJson(Map<String, dynamic> json) {
133 1 : final access = json['access'];
134 1 : return EnableBankingSession(
135 2 : id: json['session_id'].toString(),
136 4 : validUntil: access is Map ? DateTime.tryParse(access['valid_until']?.toString() ?? '') : null,
137 1 : accounts: (json['accounts'] as List? ?? [])
138 1 : .whereType<Map<String, dynamic>>()
139 1 : .map(EnableBankingAccount.fromJson)
140 1 : .toList(),
141 : );
142 : }
143 :
144 2 : Map<String, dynamic> toJson() => {
145 1 : 'session_id': id,
146 4 : 'access': {'valid_until': validUntil?.toUtc().toIso8601String()},
147 5 : 'accounts': accounts.map((e) => e.toJson()).toList(),
148 : };
149 : }
150 :
151 : class EnableBankingClient {
152 3 : static final baseUrl = Uri.parse('https://api.enablebanking.com');
153 : static const timeout = Duration(seconds: 90);
154 : static const tokenLifetime = Duration(hours: 1);
155 :
156 : final String applicationId;
157 : final RSAPrivateKey _key;
158 : final http.Client _client;
159 : final DateTime Function() _now;
160 :
161 1 : EnableBankingClient({
162 : required String applicationId,
163 : required String privateKey,
164 : http.Client? client,
165 : DateTime Function()? now,
166 1 : }) : applicationId = applicationId.trim(),
167 1 : _key = parsePrivateKey(privateKey),
168 1 : _client = client ?? http.Client(),
169 : _now = now ?? DateTime.now;
170 :
171 0 : void close() => _client.close();
172 :
173 : // Canonical PEM of a private key pasted with or without line breaks
174 1 : static String normalizePrivateKey(String pem) {
175 2 : final match = RegExp(r'-----BEGIN ([A-Z ]*PRIVATE KEY)-----(.+?)-----END \1-----', dotAll: true).firstMatch(pem);
176 : if (match == null) {
177 3 : throw EnableBankingException(AppLocale.labels.enableBankingKeyInvalid);
178 : }
179 3 : final body = match.group(2)!.replaceAll(RegExp(r'\s'), '');
180 8 : final lines = [for (var i = 0; i < body.length; i += 64) body.substring(i, (i + 64).clamp(0, body.length))];
181 4 : return '-----BEGIN ${match.group(1)}-----\n${lines.join('\n')}\n-----END ${match.group(1)}-----';
182 : }
183 :
184 : // Accepts PKCS#1 and PKCS#8 keys
185 1 : static RSAPrivateKey parsePrivateKey(String pem) {
186 1 : final normalized = normalizePrivateKey(pem);
187 : try {
188 2 : final key = RSAKeyParser().parse(normalized);
189 1 : if (key is RSAPrivateKey) {
190 : return key;
191 : }
192 : } catch (_) {
193 : // Reported below
194 : }
195 0 : throw EnableBankingException(AppLocale.labels.enableBankingKeyNotRsa);
196 : }
197 :
198 1 : static double parseAmount(dynamic value) {
199 3 : final result = double.tryParse(value?.toString().trim() ?? '');
200 : if (result == null) {
201 0 : throw EnableBankingException(AppLocale.labels.integrationInvalidAmount('$value'));
202 : }
203 : return result;
204 : }
205 :
206 1 : static DateTime? parseDate(dynamic value) {
207 2 : final date = DateTime.tryParse(value?.toString() ?? '');
208 4 : return date == null ? null : DateTime(date.year, date.month, date.day);
209 : }
210 :
211 1 : static String _date(DateTime value) =>
212 10 : '${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
213 :
214 3 : static String _base64Url(List<int> bytes) => base64Url.encode(bytes).replaceAll('=', '');
215 :
216 1 : static String extractCode(String input, {String? state, bool requireState = false}) {
217 1 : final value = input.trim();
218 1 : final uri = Uri.tryParse(value);
219 : if (requireState) {
220 3 : final returned = uri != null && uri.hasQuery ? uri.queryParameters['state'] : null;
221 3 : final hasError = uri != null && uri.hasQuery && uri.queryParameters.containsKey('error');
222 2 : if (!hasError && (state == null || state.isEmpty || returned == null || returned != state)) {
223 3 : throw EnableBankingException(AppLocale.labels.enableBankingStateMismatch);
224 : }
225 : }
226 1 : if (uri != null && uri.hasQuery) {
227 4 : final error = uri.queryParameters['error_description'] ?? uri.queryParameters['error'];
228 : if (error != null) {
229 3 : throw EnableBankingException(AppLocale.labels.integrationServiceError(error));
230 : }
231 2 : final returned = uri.queryParameters['state'];
232 1 : if (state != null && returned != null && returned != state) {
233 3 : throw EnableBankingException(AppLocale.labels.enableBankingStateMismatch);
234 : }
235 2 : final code = uri.queryParameters['code'];
236 1 : if (code != null && code.isNotEmpty) {
237 : return code;
238 : }
239 : }
240 2 : if (value.isEmpty || value.contains('://')) {
241 3 : throw EnableBankingException(AppLocale.labels.enableBankingNoCode);
242 : }
243 : return value;
244 : }
245 :
246 1 : String token() {
247 4 : final now = _now().millisecondsSinceEpoch ~/ 1000;
248 5 : final header = _base64Url(utf8.encode(jsonEncode({'typ': 'JWT', 'alg': 'RS256', 'kid': applicationId})));
249 4 : final payload = _base64Url(utf8.encode(jsonEncode({
250 : 'iss': 'enablebanking.com',
251 : 'aud': 'api.enablebanking.com',
252 : 'iat': now,
253 2 : 'exp': now + tokenLifetime.inSeconds,
254 : })));
255 1 : final input = '$header.$payload';
256 4 : final signature = Signer(RSASigner(RSASignDigest.SHA256, privateKey: _key)).sign(input);
257 3 : return '$input.${_base64Url(signature.bytes)}';
258 : }
259 :
260 1 : Future<dynamic> _send(String method, String path, {Map<String, dynamic>? query, Object? body}) async {
261 4 : final uri = baseUrl.replace(path: path, queryParameters: query?.isEmpty == true ? null : query);
262 1 : final request = http.Request(method, uri)
263 4 : ..headers['Authorization'] = 'Bearer ${token()}'
264 2 : ..headers['Accept'] = 'application/json';
265 : if (body != null) {
266 : request
267 2 : ..headers['Content-Type'] = 'application/json'
268 2 : ..body = jsonEncode(body);
269 : }
270 4 : final response = await http.Response.fromStream(await _client.send(request).timeout(timeout));
271 2 : final text = utf8.decode(response.bodyBytes);
272 : dynamic json;
273 : try {
274 2 : json = text.isEmpty ? null : jsonDecode(text);
275 0 : } on FormatException {
276 : json = null;
277 : }
278 4 : if (response.statusCode < 200 || response.statusCode >= 300) {
279 2 : final message = json is Map ? (json['message'] ?? json['detail'] ?? json['error']) : null;
280 2 : if (response.statusCode == 401 || response.statusCode == 403) {
281 1 : throw EnableBankingException(
282 : message != null
283 3 : ? AppLocale.labels.integrationServiceError('$message')
284 0 : : AppLocale.labels.integrationAccessDenied,
285 1 : response.statusCode,
286 : );
287 : }
288 0 : throw EnableBankingException(
289 : message != null
290 0 : ? AppLocale.labels.integrationServiceError('$message')
291 0 : : AppLocale.labels.integrationHttpError('${response.statusCode}'),
292 0 : response.statusCode,
293 : );
294 : }
295 : return json;
296 : }
297 :
298 1 : Future<List<EnableBankingAspsp>> getAspsps(String country) async {
299 : final json =
300 4 : await _send('GET', '/aspsps', query: {'country': country.trim().toUpperCase(), 'psu_type': 'personal'});
301 1 : return ((json as Map?)?['aspsps'] as List? ?? [])
302 1 : .whereType<Map<String, dynamic>>()
303 1 : .map(EnableBankingAspsp.fromJson)
304 1 : .toList();
305 : }
306 :
307 1 : Future<Uri> startAuthorization({
308 : required EnableBankingAspsp aspsp,
309 : required String redirectUrl,
310 : required String state,
311 : Duration validity = const Duration(days: 90),
312 : }) async {
313 1 : final maximum = aspsp.maximumConsentValidity;
314 1 : final duration = maximum != null && maximum < validity ? maximum : validity;
315 2 : final json = await _send('POST', '/auth', body: {
316 6 : 'access': {'valid_until': _now().add(duration).toUtc().toIso8601String()},
317 3 : 'aspsp': {'name': aspsp.name, 'country': aspsp.country},
318 : 'state': state,
319 1 : 'redirect_url': redirectUrl.trim(),
320 : 'psu_type': 'personal',
321 : });
322 3 : final url = Uri.tryParse((json as Map?)?['url']?.toString() ?? '');
323 2 : if (url == null || url.scheme != 'https') {
324 0 : throw EnableBankingException(AppLocale.labels.enableBankingNoAuthPage);
325 : }
326 : return url;
327 : }
328 :
329 1 : Future<EnableBankingSession> createSession(String code) async {
330 2 : final json = await _send('POST', '/sessions', body: {'code': code});
331 1 : return EnableBankingSession.fromJson(json as Map<String, dynamic>);
332 : }
333 :
334 0 : Future<void> deleteSession(String sessionId) => _send('DELETE', '/sessions/${Uri.encodeComponent(sessionId)}');
335 :
336 0 : Future<List<EnableBankingBalance>> getBalances(String uid) async {
337 0 : final json = await _send('GET', '/accounts/${Uri.encodeComponent(uid)}/balances');
338 0 : return ((json as Map?)?['balances'] as List? ?? [])
339 0 : .whereType<Map<String, dynamic>>()
340 0 : .map(EnableBankingBalance.fromJson)
341 0 : .toList();
342 : }
343 :
344 : // All pages of transactions since [from]
345 1 : Future<List<EnableBankingTransaction>> getTransactions(String uid, {DateTime? from}) async {
346 1 : final result = <EnableBankingTransaction>[];
347 : String? continuation;
348 : var pages = 0;
349 : do {
350 4 : final json = await _send('GET', '/accounts/${Uri.encodeComponent(uid)}/transactions', query: {
351 2 : if (from != null) 'date_from': _date(from),
352 1 : if (continuation != null) 'continuation_key': continuation,
353 : }) as Map?;
354 2 : result.addAll((json?['transactions'] as List? ?? [])
355 1 : .whereType<Map<String, dynamic>>()
356 1 : .map(EnableBankingTransaction.fromJson));
357 2 : final next = json?['continuation_key']?.toString();
358 2 : continuation = next == null || next.isEmpty || next == continuation ? null : next;
359 2 : } while (continuation != null && ++pages < 100);
360 : return result;
361 : }
362 : }
|