LCOV - code coverage report
Current view: top level - lib/_classes/integration/enablebanking - enablebanking_client.dart Coverage Total Hit
Test: lcov.info Lines: 0.0 % 173 0
Test Date: 2026-11-14 15:08:11 Functions: - 0 0

            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            0 :   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            0 :   const EnableBankingAspsp({required this.name, required this.country, this.maximumConsentValidity});
      28              : 
      29            0 :   factory EnableBankingAspsp.fromJson(Map<String, dynamic> json) => EnableBankingAspsp(
      30            0 :         name: json['name'].toString(),
      31            0 :         country: json['country'].toString(),
      32            0 :         maximumConsentValidity: json['maximum_consent_validity'] is num
      33            0 :             ? 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            0 :   const EnableBankingAccount({required this.uid, required this.name, this.iban, this.currency});
      45              : 
      46            0 :   String get key => iban?.replaceAll(' ', '').toUpperCase() ?? uid;
      47              : 
      48            0 :   factory EnableBankingAccount.fromJson(Map<String, dynamic> json) {
      49            0 :     final accountId = json['account_id'];
      50            0 :     return EnableBankingAccount(
      51            0 :       uid: json['uid'].toString(),
      52            0 :       iban: accountId is Map ? accountId['iban']?.toString() : null,
      53            0 :       name: (json['name'] ?? json['product'] ?? json['details'] ?? '').toString().trim(),
      54            0 :       currency: json['currency']?.toString(),
      55              :     );
      56              :   }
      57              : 
      58            0 :   Map<String, dynamic> toJson() => {
      59            0 :         'uid': uid,
      60            0 :         'account_id': {'iban': iban},
      61            0 :         'name': name,
      62            0 :         '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            0 :   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            0 :   bool get isBooked => status == null || status == 'BOOK';
     101              : 
     102            0 :   factory EnableBankingTransaction.fromJson(Map<String, dynamic> json) {
     103            0 :     final value = json['transaction_amount'] as Map<String, dynamic>? ?? {};
     104            0 :     final isDebit = json['credit_debit_indicator'] == 'DBIT';
     105            0 :     final amount = EnableBankingClient.parseAmount(value['amount']).abs();
     106            0 :     final party = (isDebit ? json['creditor'] : json['debtor']) as Map<String, dynamic>?;
     107            0 :     final remittance = (json['remittance_information'] as List? ?? []).map((e) => e.toString().trim()).join(' ');
     108            0 :     final title = [party?['name']?.toString().trim() ?? '', remittance, json['note']?.toString().trim() ?? '']
     109            0 :         .where((e) => e.isNotEmpty)
     110            0 :         .take(2)
     111            0 :         .join(' — ');
     112            0 :     return EnableBankingTransaction(
     113            0 :       reference: (json['entry_reference'] ?? json['transaction_id'])?.toString(),
     114            0 :       amount: isDebit ? -amount : amount,
     115            0 :       currency: (value['currency'] ?? '').toString(),
     116            0 :       status: json['status']?.toString(),
     117            0 :       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            0 :   const EnableBankingSession({required this.id, required this.accounts, this.validUntil});
     129              : 
     130            0 :   bool get isValid => validUntil == null || validUntil!.isAfter(DateTime.now());
     131              : 
     132            0 :   factory EnableBankingSession.fromJson(Map<String, dynamic> json) {
     133            0 :     final access = json['access'];
     134            0 :     return EnableBankingSession(
     135            0 :       id: json['session_id'].toString(),
     136            0 :       validUntil: access is Map ? DateTime.tryParse(access['valid_until']?.toString() ?? '') : null,
     137            0 :       accounts: (json['accounts'] as List? ?? [])
     138            0 :           .whereType<Map<String, dynamic>>()
     139            0 :           .map(EnableBankingAccount.fromJson)
     140            0 :           .toList(),
     141              :     );
     142              :   }
     143              : 
     144            0 :   Map<String, dynamic> toJson() => {
     145            0 :         'session_id': id,
     146            0 :         'access': {'valid_until': validUntil?.toUtc().toIso8601String()},
     147            0 :         'accounts': accounts.map((e) => e.toJson()).toList(),
     148              :       };
     149              : }
     150              : 
     151              : class EnableBankingClient {
     152            0 :   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            0 :   EnableBankingClient({
     162              :     required String applicationId,
     163              :     required String privateKey,
     164              :     http.Client? client,
     165              :     DateTime Function()? now,
     166            0 :   })  : applicationId = applicationId.trim(),
     167            0 :         _key = parsePrivateKey(privateKey),
     168            0 :         _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            0 :   static String normalizePrivateKey(String pem) {
     175            0 :     final match = RegExp(r'-----BEGIN ([A-Z ]*PRIVATE KEY)-----(.+?)-----END \1-----', dotAll: true).firstMatch(pem);
     176              :     if (match == null) {
     177            0 :       throw EnableBankingException(AppLocale.labels.enableBankingKeyInvalid);
     178              :     }
     179            0 :     final body = match.group(2)!.replaceAll(RegExp(r'\s'), '');
     180            0 :     final lines = [for (var i = 0; i < body.length; i += 64) body.substring(i, (i + 64).clamp(0, body.length))];
     181            0 :     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            0 :   static RSAPrivateKey parsePrivateKey(String pem) {
     186            0 :     final normalized = normalizePrivateKey(pem);
     187              :     try {
     188            0 :       final key = RSAKeyParser().parse(normalized);
     189            0 :       if (key is RSAPrivateKey) {
     190              :         return key;
     191              :       }
     192              :     } catch (_) {
     193              :       // Reported below
     194              :     }
     195            0 :     throw EnableBankingException(AppLocale.labels.enableBankingKeyNotRsa);
     196              :   }
     197              : 
     198            0 :   static double parseAmount(dynamic value) {
     199            0 :     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            0 :   static DateTime? parseDate(dynamic value) {
     207            0 :     final date = DateTime.tryParse(value?.toString() ?? '');
     208            0 :     return date == null ? null : DateTime(date.year, date.month, date.day);
     209              :   }
     210              : 
     211            0 :   static String _date(DateTime value) =>
     212            0 :       '${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
     213              : 
     214            0 :   static String _base64Url(List<int> bytes) => base64Url.encode(bytes).replaceAll('=', '');
     215              : 
     216            0 :   static String extractCode(String input, {String? state, bool requireState = false}) {
     217            0 :     final value = input.trim();
     218            0 :     final uri = Uri.tryParse(value);
     219              :     if (requireState) {
     220            0 :       final returned = uri != null && uri.hasQuery ? uri.queryParameters['state'] : null;
     221            0 :       final hasError = uri != null && uri.hasQuery && uri.queryParameters.containsKey('error');
     222            0 :       if (!hasError && (state == null || state.isEmpty || returned == null || returned != state)) {
     223            0 :         throw EnableBankingException(AppLocale.labels.enableBankingStateMismatch);
     224              :       }
     225              :     }
     226            0 :     if (uri != null && uri.hasQuery) {
     227            0 :       final error = uri.queryParameters['error_description'] ?? uri.queryParameters['error'];
     228              :       if (error != null) {
     229            0 :         throw EnableBankingException(AppLocale.labels.integrationServiceError(error));
     230              :       }
     231            0 :       final returned = uri.queryParameters['state'];
     232            0 :       if (state != null && returned != null && returned != state) {
     233            0 :         throw EnableBankingException(AppLocale.labels.enableBankingStateMismatch);
     234              :       }
     235            0 :       final code = uri.queryParameters['code'];
     236            0 :       if (code != null && code.isNotEmpty) {
     237              :         return code;
     238              :       }
     239              :     }
     240            0 :     if (value.isEmpty || value.contains('://')) {
     241            0 :       throw EnableBankingException(AppLocale.labels.enableBankingNoCode);
     242              :     }
     243              :     return value;
     244              :   }
     245              : 
     246            0 :   String token() {
     247            0 :     final now = _now().millisecondsSinceEpoch ~/ 1000;
     248            0 :     final header = _base64Url(utf8.encode(jsonEncode({'typ': 'JWT', 'alg': 'RS256', 'kid': applicationId})));
     249            0 :     final payload = _base64Url(utf8.encode(jsonEncode({
     250              :       'iss': 'enablebanking.com',
     251              :       'aud': 'api.enablebanking.com',
     252              :       'iat': now,
     253            0 :       'exp': now + tokenLifetime.inSeconds,
     254              :     })));
     255            0 :     final input = '$header.$payload';
     256            0 :     final signature = Signer(RSASigner(RSASignDigest.SHA256, privateKey: _key)).sign(input);
     257            0 :     return '$input.${_base64Url(signature.bytes)}';
     258              :   }
     259              : 
     260            0 :   Future<dynamic> _send(String method, String path, {Map<String, dynamic>? query, Object? body}) async {
     261            0 :     final uri = baseUrl.replace(path: path, queryParameters: query?.isEmpty == true ? null : query);
     262            0 :     final request = http.Request(method, uri)
     263            0 :       ..headers['Authorization'] = 'Bearer ${token()}'
     264            0 :       ..headers['Accept'] = 'application/json';
     265              :     if (body != null) {
     266              :       request
     267            0 :         ..headers['Content-Type'] = 'application/json'
     268            0 :         ..body = jsonEncode(body);
     269              :     }
     270            0 :     final response = await http.Response.fromStream(await _client.send(request).timeout(timeout));
     271            0 :     final text = utf8.decode(response.bodyBytes);
     272              :     dynamic json;
     273              :     try {
     274            0 :       json = text.isEmpty ? null : jsonDecode(text);
     275            0 :     } on FormatException {
     276              :       json = null;
     277              :     }
     278            0 :     if (response.statusCode < 200 || response.statusCode >= 300) {
     279            0 :       final message = json is Map ? (json['message'] ?? json['detail'] ?? json['error']) : null;
     280            0 :       if (response.statusCode == 401 || response.statusCode == 403) {
     281            0 :         throw EnableBankingException(
     282              :           message != null
     283            0 :               ? AppLocale.labels.integrationServiceError('$message')
     284            0 :               : AppLocale.labels.integrationAccessDenied,
     285            0 :           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            0 :   Future<List<EnableBankingAspsp>> getAspsps(String country) async {
     299              :     final json =
     300            0 :         await _send('GET', '/aspsps', query: {'country': country.trim().toUpperCase(), 'psu_type': 'personal'});
     301            0 :     return ((json as Map?)?['aspsps'] as List? ?? [])
     302            0 :         .whereType<Map<String, dynamic>>()
     303            0 :         .map(EnableBankingAspsp.fromJson)
     304            0 :         .toList();
     305              :   }
     306              : 
     307            0 :   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            0 :     final maximum = aspsp.maximumConsentValidity;
     314            0 :     final duration = maximum != null && maximum < validity ? maximum : validity;
     315            0 :     final json = await _send('POST', '/auth', body: {
     316            0 :       'access': {'valid_until': _now().add(duration).toUtc().toIso8601String()},
     317            0 :       'aspsp': {'name': aspsp.name, 'country': aspsp.country},
     318              :       'state': state,
     319            0 :       'redirect_url': redirectUrl.trim(),
     320              :       'psu_type': 'personal',
     321              :     });
     322            0 :     final url = Uri.tryParse((json as Map?)?['url']?.toString() ?? '');
     323            0 :     if (url == null || url.scheme != 'https') {
     324            0 :       throw EnableBankingException(AppLocale.labels.enableBankingNoAuthPage);
     325              :     }
     326              :     return url;
     327              :   }
     328              : 
     329            0 :   Future<EnableBankingSession> createSession(String code) async {
     330            0 :     final json = await _send('POST', '/sessions', body: {'code': code});
     331            0 :     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            0 :   Future<List<EnableBankingTransaction>> getTransactions(String uid, {DateTime? from}) async {
     346            0 :     final result = <EnableBankingTransaction>[];
     347              :     String? continuation;
     348              :     var pages = 0;
     349              :     do {
     350            0 :       final json = await _send('GET', '/accounts/${Uri.encodeComponent(uid)}/transactions', query: {
     351            0 :         if (from != null) 'date_from': _date(from),
     352            0 :         if (continuation != null) 'continuation_key': continuation,
     353              :       }) as Map?;
     354            0 :       result.addAll((json?['transactions'] as List? ?? [])
     355            0 :           .whereType<Map<String, dynamic>>()
     356            0 :           .map(EnableBankingTransaction.fromJson));
     357            0 :       final next = json?['continuation_key']?.toString();
     358            0 :       continuation = next == null || next.isEmpty || next == continuation ? null : next;
     359            0 :     } while (continuation != null && ++pages < 100);
     360              :     return result;
     361              :   }
     362              : }
        

Generated by: LCOV version 2.0-1