LCOV - code coverage report
Current view: top level - _classes/integration/simplefin - simplefin_client.dart Coverage Total Hit
Test: lcov.info Lines: 87.9 % 91 80
Test Date: 2026-11-14 15:03:01 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:http/http.dart' as http;
       8              : 
       9              : // SimpleFIN protocol (https://www.simplefin.org/protocol.html)
      10              : class SimpleFinException implements Exception {
      11              :   final String message;
      12              :   final int? statusCode;
      13              : 
      14            1 :   const SimpleFinException(this.message, [this.statusCode]);
      15              : 
      16            0 :   @override
      17            0 :   String toString() => message;
      18              : }
      19              : 
      20              : class SimpleFinTransaction {
      21              :   final String id;
      22              :   final DateTime? posted;
      23              :   final DateTime? transactedAt;
      24              :   final double amount;
      25              :   final String description;
      26              :   final bool pending;
      27              : 
      28            1 :   const SimpleFinTransaction({
      29              :     required this.id,
      30              :     required this.amount,
      31              :     required this.description,
      32              :     this.posted,
      33              :     this.transactedAt,
      34              :     this.pending = false,
      35              :   });
      36              : 
      37            3 :   DateTime? get date => posted ?? transactedAt;
      38              : 
      39            1 :   factory SimpleFinTransaction.fromJson(Map<String, dynamic> json) {
      40            2 :     final posted = SimpleFinClient.parseTime(json['posted']);
      41            1 :     return SimpleFinTransaction(
      42            2 :       id: json['id'].toString(),
      43              :       posted: posted,
      44            2 :       transactedAt: SimpleFinClient.parseTime(json['transacted_at']),
      45            2 :       amount: SimpleFinClient.parseAmount(json['amount']),
      46            3 :       description: (json['description'] ?? json['payee'] ?? '').toString().trim(),
      47              :       // If the transaction is pending, posted may be 0
      48            2 :       pending: json['pending'] == true || posted == null,
      49              :     );
      50              :   }
      51              : }
      52              : 
      53              : class SimpleFinAccount {
      54              :   final String id;
      55              :   final String name;
      56              :   final String? institution;
      57              :   final String currency;
      58              :   final double balance;
      59              :   final double? availableBalance;
      60              :   final DateTime? balanceDate;
      61              :   final List<SimpleFinTransaction> transactions;
      62              : 
      63            1 :   const SimpleFinAccount({
      64              :     required this.id,
      65              :     required this.name,
      66              :     required this.currency,
      67              :     required this.balance,
      68              :     this.institution,
      69              :     this.availableBalance,
      70              :     this.balanceDate,
      71              :     this.transactions = const [],
      72              :   });
      73              : 
      74            1 :   factory SimpleFinAccount.fromJson(Map<String, dynamic> json, Map<String, String> connections) {
      75            1 :     final org = json['org'];
      76            1 :     return SimpleFinAccount(
      77            2 :       id: json['id'].toString(),
      78            3 :       name: (json['name'] ?? '').toString().trim(),
      79            5 :       institution: connections[json['conn_id']] ?? (org is Map ? (org['name'] ?? org['domain'])?.toString() : null),
      80            2 :       currency: (json['currency'] ?? '').toString(),
      81            2 :       balance: SimpleFinClient.parseAmount(json['balance']),
      82              :       availableBalance:
      83            3 :           json['available-balance'] != null ? SimpleFinClient.parseAmount(json['available-balance']) : null,
      84            2 :       balanceDate: SimpleFinClient.parseTime(json['balance-date']),
      85            2 :       transactions: (json['transactions'] as List? ?? [])
      86            1 :           .whereType<Map<String, dynamic>>()
      87            1 :           .map(SimpleFinTransaction.fromJson)
      88            1 :           .toList(),
      89              :     );
      90              :   }
      91              : }
      92              : 
      93              : class SimpleFinAccountSet {
      94              :   final List<SimpleFinAccount> accounts;
      95              :   final List<String> errors;
      96              : 
      97            1 :   const SimpleFinAccountSet({required this.accounts, this.errors = const []});
      98              : 
      99            1 :   factory SimpleFinAccountSet.fromJson(Map<String, dynamic> json) {
     100            1 :     final connections = <String, String>{
     101            3 :       for (final item in (json['connections'] as List? ?? []).whereType<Map<String, dynamic>>())
     102            5 :         item['conn_id'].toString(): (item['name'] ?? item['org_id'] ?? '').toString(),
     103              :     };
     104            3 :     final errlist = (json['errlist'] as List? ?? []).whereType<Map<String, dynamic>>();
     105            1 :     return SimpleFinAccountSet(
     106            1 :       accounts: (json['accounts'] as List? ?? [])
     107            1 :           .whereType<Map<String, dynamic>>()
     108            3 :           .map((e) => SimpleFinAccount.fromJson(e, connections))
     109            1 :           .toList(),
     110            1 :       errors: errlist.isNotEmpty
     111            5 :           ? errlist.map((e) => (e['msg'] ?? e['code'] ?? '').toString()).toList()
     112            5 :           : (json['errors'] as List? ?? []).map((e) => e.toString()).toList(),
     113              :     );
     114              :   }
     115              : }
     116              : 
     117              : class SimpleFinClient {
     118              :   static const timeout = Duration(seconds: 90);
     119              : 
     120              :   final http.Client _client;
     121              : 
     122            1 :   SimpleFinClient({http.Client? client}) : _client = client ?? http.Client();
     123              : 
     124            0 :   void close() => _client.close();
     125              : 
     126            1 :   static double parseAmount(dynamic value) {
     127            3 :     final result = double.tryParse(value?.toString().trim() ?? '');
     128              :     if (result == null) {
     129            0 :       throw SimpleFinException(AppLocale.labels.integrationInvalidAmount('$value'));
     130              :     }
     131              :     return result;
     132              :   }
     133              : 
     134            1 :   static DateTime? parseTime(dynamic value) {
     135            3 :     final seconds = value is num ? value.toInt() : int.tryParse(value?.toString() ?? '');
     136            3 :     return seconds == null || seconds <= 0 ? null : DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
     137              :   }
     138              : 
     139            1 :   static Uri _requireHttps(Uri uri) {
     140            4 :     if (uri.scheme != 'https' || uri.host.isEmpty) {
     141            3 :       throw SimpleFinException(AppLocale.labels.integrationHttpsRequired);
     142              :     }
     143              :     return uri;
     144              :   }
     145              : 
     146            1 :   static Uri decodeToken(String token) {
     147            2 :     final value = token.replaceAll(RegExp(r'\s'), '');
     148              :     try {
     149            5 :       return _requireHttps(Uri.parse(utf8.decode(base64.decode(base64.normalize(value)))));
     150            1 :     } on FormatException {
     151            3 :       throw SimpleFinException(AppLocale.labels.simpleFinTokenInvalid);
     152              :     }
     153              :   }
     154              : 
     155            1 :   Future<Uri> claim(String token) async {
     156            5 :     final response = await _client.post(decodeToken(token), headers: {'Content-Length': '0'}).timeout(timeout);
     157            2 :     if (response.statusCode == 403) {
     158            3 :       throw SimpleFinException(AppLocale.labels.simpleFinTokenClaimed, 403);
     159              :     }
     160            1 :     _check(response);
     161            4 :     final access = _requireHttps(Uri.parse(response.body.trim()));
     162            2 :     if (!access.userInfo.contains(':')) {
     163            0 :       throw SimpleFinException(AppLocale.labels.simpleFinNoCredentials);
     164              :     }
     165              :     return access;
     166              :   }
     167              : 
     168            1 :   Future<SimpleFinAccountSet> getAccounts(
     169              :     Uri accessUrl, {
     170              :     DateTime? start,
     171              :     DateTime? end,
     172              :     bool pending = false,
     173              :     bool balancesOnly = false,
     174              :     List<String> accountIds = const [],
     175              :   }) async {
     176            1 :     _requireHttps(accessUrl);
     177            4 :     final path = accessUrl.path.endsWith('/') ? accessUrl.path : '${accessUrl.path}/';
     178            1 :     final uri = accessUrl.replace(
     179              :       userInfo: '',
     180            1 :       path: '${path}accounts',
     181            1 :       queryParameters: {
     182            4 :         if (start != null) 'start-date': '${start.millisecondsSinceEpoch ~/ 1000}',
     183            0 :         if (end != null) 'end-date': '${end.millisecondsSinceEpoch ~/ 1000}',
     184            0 :         if (pending) 'pending': '1',
     185            0 :         if (balancesOnly) 'balances-only': '1',
     186            2 :         if (accountIds.isNotEmpty) 'account': accountIds,
     187              :       },
     188              :     );
     189            4 :     final credentials = base64.encode(utf8.encode(Uri.decodeComponent(accessUrl.userInfo)));
     190            5 :     final response = await _client.get(uri, headers: {'Authorization': 'Basic $credentials'}).timeout(timeout);
     191            2 :     if (response.statusCode == 402) {
     192            0 :       throw SimpleFinException(AppLocale.labels.integrationPaymentRequired, 402);
     193              :     }
     194            2 :     if (response.statusCode == 403) {
     195            3 :       throw SimpleFinException(AppLocale.labels.integrationAccessDenied, 403);
     196              :     }
     197            1 :     _check(response);
     198            3 :     final json = jsonDecode(utf8.decode(response.bodyBytes));
     199            1 :     if (json is! Map<String, dynamic>) {
     200            0 :       throw SimpleFinException(AppLocale.labels.integrationUnexpectedResponse);
     201              :     }
     202            1 :     return SimpleFinAccountSet.fromJson(json);
     203              :   }
     204              : 
     205            1 :   static void _check(http.Response response) {
     206            2 :     if (response.statusCode != 200) {
     207            0 :       throw SimpleFinException(AppLocale.labels.integrationHttpError('${response.statusCode}'), response.statusCode);
     208              :     }
     209              :   }
     210              : }
        

Generated by: LCOV version 2.0-1