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 0 : 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 2 : 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 1 : 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 2 : 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 2 : 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 0 : ? errlist.map((e) => (e['msg'] ?? e['code'] ?? '').toString()).toList()
112 4 : : (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 2 : SimpleFinClient({http.Client? client}) : _client = client ?? http.Client();
123 :
124 3 : 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 0 : 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 0 : } on FormatException {
151 0 : 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 0 : 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 1 : 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 0 : 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 : }
|