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:async';
5 :
6 : import 'package:app_finance/_classes/herald/app_design.dart';
7 : import 'package:app_finance/_classes/herald/app_locale.dart';
8 : import 'package:app_finance/_classes/integration/fints/fints_importer.dart';
9 : import 'package:app_finance/_classes/integration/fints/fints_settings.dart';
10 : import 'package:app_finance/_classes/storage/app_data.dart';
11 : import 'package:app_finance/_classes/storage/app_preferences.dart';
12 : import 'package:app_finance/_classes/storage/transaction_log.dart';
13 : import 'package:app_finance/_configs/theme_helper.dart';
14 : import 'package:app_finance/_ext/build_context_ext.dart';
15 : import 'package:app_finance/design/button/full_sized_button_widget.dart';
16 : import 'package:app_finance/design/form/list_selector_item.dart';
17 : import 'package:app_finance/design/generic/loading_widget.dart';
18 : import 'package:app_finance/design/wrapper/input_wrapper.dart';
19 : import 'package:app_finance/pages/integration/widgets/fints_dialogs.dart';
20 : import 'package:app_finance/pages/integration/widgets/integration_account_card.dart';
21 : import 'package:fints/fints.dart';
22 : import 'package:flutter/foundation.dart';
23 : import 'package:material_ui/material_ui.dart';
24 : import 'package:provider/provider.dart';
25 :
26 : typedef FinTsClientFactory = FinTsClient Function(FinTsSettings settings, String pin, FinTsClientOptions options);
27 :
28 : class FinTsBankAccount extends IntegrationTarget {
29 : final Account account;
30 : final String key;
31 : Balance? balance;
32 :
33 1 : FinTsBankAccount(this.account, {super.appAccount, super.category, required super.from})
34 1 : : key = FinTsSettings.accountKey(account);
35 :
36 1 : String get title {
37 2 : final name = account.productName ?? account.ownerName ?? '';
38 2 : final number = account.iban ?? account.accountNumber ?? '';
39 6 : final masked = number.length > 4 ? '…${number.substring(number.length - 4)}' : number;
40 5 : return [name, masked].where((e) => e.isNotEmpty).join(' ');
41 : }
42 : }
43 :
44 : class FinTsWidget extends StatefulWidget {
45 : final FinTsClientFactory? clientFactory;
46 : final bool isWeb;
47 :
48 3 : const FinTsWidget({super.key, this.clientFactory, this.isWeb = kIsWeb});
49 :
50 1 : @override
51 1 : FinTsWidgetState createState() => FinTsWidgetState();
52 : }
53 :
54 : class FinTsWidgetState extends State<FinTsWidget> {
55 : late final FinTsSettings settings;
56 : late final FinTsDialogs dialogs;
57 : late final TextEditingController url;
58 : late final TextEditingController bankCode;
59 : late final TextEditingController userId;
60 : late final TextEditingController customerId;
61 : late final TextEditingController productId;
62 : FinTsClient? client;
63 : List<FinTsBankAccount> accounts = [];
64 : bool isLoading = false;
65 : String? error;
66 : String? notice;
67 :
68 1 : @override
69 : void initState() {
70 1 : super.initState();
71 2 : settings = FinTsSettings.load();
72 4 : dialogs = FinTsDialogs(() => context);
73 4 : url = TextEditingController(text: settings.url);
74 4 : bankCode = TextEditingController(text: settings.bankCode);
75 4 : userId = TextEditingController(text: settings.userId);
76 4 : customerId = TextEditingController(text: settings.customerId);
77 4 : productId = TextEditingController(text: settings.productId);
78 : }
79 :
80 1 : @override
81 : void dispose() {
82 2 : unawaited(_close());
83 7 : for (final controller in [url, bankCode, userId, customerId, productId]) {
84 1 : controller.dispose();
85 : }
86 1 : super.dispose();
87 : }
88 :
89 1 : Future<void> _close() async {
90 1 : final current = client;
91 1 : client = null;
92 : if (current == null) {
93 : return;
94 : }
95 : try {
96 1 : await current.close();
97 : } finally {
98 2 : await FinTsSettings.saveState(current.state);
99 : }
100 : }
101 :
102 1 : FinTsClient _createClient(String pin) {
103 1 : final options = FinTsClientOptions(
104 1 : state: FinTsSettings.loadState(),
105 2 : tanCallback: dialogs.askTan,
106 2 : tanMethodSelector: dialogs.selectTanMethod,
107 2 : tanMediumSelector: dialogs.selectTanMedium,
108 0 : onBankMessage: (message) => _notify('${message.subject}: ${message.text}'),
109 0 : onWarning: (warning) => _notify('${warning.code}: ${warning.text}'),
110 : );
111 4 : return widget.clientFactory?.call(settings, pin, options) ??
112 0 : FinTs4Client(config: settings.toConfig(), pin: pin, options: options, namespaces: settings.namespace);
113 : }
114 :
115 0 : void _notify(String text) {
116 0 : if (mounted) {
117 0 : ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 8)));
118 : }
119 : }
120 :
121 1 : Future<void> _run(Future<void> Function() callback) async {
122 2 : setState(() {
123 1 : isLoading = true;
124 1 : error = null;
125 1 : notice = null;
126 : });
127 : try {
128 1 : await callback();
129 0 : } on FinTsLoginDataChangedException catch (e) {
130 0 : error = AppLocale.labels.finTsLoginChanged(e.message);
131 0 : await _close();
132 0 : } on FinTsException catch (e) {
133 0 : error = e.message;
134 0 : } on ArgumentError catch (e) {
135 0 : error = e.message?.toString() ?? e.toString();
136 : } catch (e) {
137 0 : error = e.toString();
138 : } finally {
139 1 : final current = client;
140 : if (current != null) {
141 2 : await FinTsSettings.saveState(current.state);
142 : }
143 1 : if (mounted) {
144 3 : setState(() => isLoading = false);
145 : }
146 : }
147 : }
148 :
149 1 : Future<void> saveSettings() async {
150 1 : settings
151 4 : ..url = url.text.trim()
152 4 : ..bankCode = bankCode.text.trim()
153 4 : ..userId = userId.text.trim()
154 4 : ..customerId = customerId.text.trim()
155 4 : ..productId = productId.text.trim();
156 2 : await settings.save();
157 : }
158 :
159 3 : Future<void> connect() => _run(() async {
160 1 : await saveSettings();
161 2 : if (!settings.isComplete) {
162 0 : throw ArgumentError(AppLocale.labels.finTsIncomplete);
163 : }
164 2 : settings.toConfig();
165 2 : final pin = await dialogs.askPin();
166 : if (pin == null) {
167 : return;
168 : }
169 1 : await _close();
170 1 : final current = _createClient(pin);
171 1 : final refusal = current.loginRefusal;
172 1 : if (refusal is FinTsLoginDataChangedException) {
173 : throw refusal;
174 : } else if (refusal != null) {
175 0 : if (!await dialogs.confirm(AppLocale.labels.finTsLoginRefused(refusal.message))) {
176 0 : await current.close();
177 : return;
178 : }
179 0 : await current.clearLoginRefusal(pin);
180 : }
181 1 : client = current;
182 1 : final list = await current.getAccounts();
183 1 : final defaultBudget = AppPreferences.get(AppPreferences.prefBudget);
184 2 : final monthAgo = DateTime.now().subtract(const Duration(days: 30));
185 3 : accounts = list.map((account) {
186 1 : final key = FinTsSettings.accountKey(account);
187 1 : return FinTsBankAccount(
188 : account,
189 3 : appAccount: settings.links[key],
190 : category: defaultBudget,
191 3 : from: settings.imported[key] ?? monthAgo,
192 : );
193 1 : }).toList();
194 2 : for (final item in accounts) {
195 : try {
196 3 : item.balance = await current.getBalance(item.account);
197 0 : } on FinTsUnsupportedException {
198 : // The bank offers no balance for this account
199 : }
200 : }
201 2 : if (accounts.isEmpty) {
202 0 : notice = AppLocale.labels.integrationNoAccounts;
203 : }
204 : });
205 :
206 3 : Future<void> disconnect() => _run(() async {
207 1 : await _close();
208 2 : accounts = [];
209 : });
210 :
211 3 : Future<void> importTransactions(FinTsBankAccount item) => _run(() async {
212 1 : final current = client;
213 : if (current == null) {
214 : return;
215 : }
216 2 : if (item.appAccount == null || item.category == null) {
217 0 : throw ArgumentError(AppLocale.labels.integrationSelectTarget);
218 : }
219 2 : final state = Provider.of<AppData>(context, listen: false);
220 : List<Transaction> transactions;
221 : try {
222 3 : transactions = await current.getTransactions(item.account, from: item.from);
223 0 : } on FinTsIncompleteResultException<List<Transaction>> catch (e) {
224 0 : transactions = e.partialResult ?? const [];
225 0 : _notify(e.feedback.map((f) => '${f.code}: ${f.text}').join('\n'));
226 : }
227 1 : final importer = FinTsImporter(
228 2 : exists: (uuid) => state.getByUuid(uuid) != null,
229 3 : add: (value, uuid) => TransactionLog.save(state.add(value, uuid)),
230 : );
231 1 : final result = importer.run(
232 1 : accountKey: item.key,
233 : transactions: transactions,
234 1 : account: item.appAccount!,
235 1 : category: item.category!,
236 : );
237 1 : await state.restate();
238 1 : final today = DateTime.now();
239 5 : settings.links[item.key] = item.appAccount!;
240 8 : settings.imported[item.key] = DateTime(today.year, today.month, today.day);
241 2 : await settings.save();
242 3 : notice = AppLocale.labels.integrationImported(result.added, result.pending, result.skipped);
243 : });
244 :
245 1 : Widget buildSettings(BuildContext context) {
246 1 : final isConnected = client != null;
247 1 : return Column(
248 1 : crossAxisAlignment: AppDesign.getAlignment(),
249 1 : children: [
250 1 : InputWrapper.text(
251 2 : title: AppLocale.labels.finTsUrl,
252 : tooltip: 'https://…',
253 1 : controller: url,
254 : isRequired: true,
255 : isDisabled: isConnected,
256 : inputType: TextInputType.url,
257 : ),
258 1 : InputWrapper.text(
259 2 : title: AppLocale.labels.finTsBankCode,
260 1 : controller: bankCode,
261 : isRequired: true,
262 : isDisabled: isConnected,
263 : inputType: TextInputType.number,
264 : ),
265 1 : InputWrapper.text(
266 2 : title: AppLocale.labels.finTsUserId,
267 1 : controller: userId,
268 : isRequired: true,
269 : isDisabled: isConnected,
270 : ),
271 1 : InputWrapper.text(
272 2 : title: AppLocale.labels.finTsCustomerId,
273 1 : controller: customerId,
274 : isDisabled: isConnected,
275 : ),
276 1 : InputWrapper.text(
277 2 : title: AppLocale.labels.finTsProductId,
278 2 : tooltip: AppLocale.labels.finTsProductIdTooltip,
279 1 : controller: productId,
280 : isRequired: true,
281 : isDisabled: isConnected,
282 : ),
283 1 : InputWrapper.select(
284 2 : title: AppLocale.labels.finTsVersion,
285 2 : value: settings.version,
286 : isDisabled: isConnected,
287 1 : options: <ListSelectorItem>[
288 3 : for (final version in FinTsSettings.namespaces.keys) ListSelectorItem(id: version, name: 'FinTS $version'),
289 : ],
290 0 : onChange: (value) => setState(() => settings.version = value ?? settings.version),
291 : ),
292 : ],
293 : );
294 : }
295 :
296 1 : Widget buildAccount(BuildContext context, FinTsBankAccount item, AppData state) {
297 2 : final balance = item.balance?.booked;
298 1 : return IntegrationAccountCard(
299 1 : title: item.title,
300 5 : subtitle: balance != null ? '${AppLocale.labels.balance}: ${balance.value} ${balance.currency}' : null,
301 : target: item,
302 : state: state,
303 1 : isDisabled: isLoading,
304 0 : onChanged: () => setState(() {}),
305 2 : onImport: () => importTransactions(item),
306 : );
307 : }
308 :
309 1 : @override
310 : Widget build(BuildContext context) {
311 1 : final textTheme = context.textTheme;
312 1 : final colorScheme = context.colorScheme;
313 2 : if (widget.isWeb) {
314 0 : return Text(AppLocale.labels.finTsWebUnsupported, style: textTheme.bodyLarge);
315 : }
316 2 : return Consumer<AppData>(builder: (context, state, _) {
317 1 : return Column(
318 1 : crossAxisAlignment: AppDesign.getAlignment(),
319 1 : children: [
320 4 : Text(AppLocale.labels.finTsDescription, style: textTheme.bodyMedium),
321 : ThemeHelper.hIndent2x,
322 1 : buildSettings(context),
323 : ThemeHelper.hIndent2x,
324 1 : if (error != null) ...[
325 0 : Text(error!, style: textTheme.bodyLarge?.copyWith(color: colorScheme.error)),
326 : ThemeHelper.hIndent,
327 : ],
328 2 : if (notice != null) ...[
329 3 : Text(notice!, style: textTheme.bodyLarge),
330 : ThemeHelper.hIndent,
331 : ],
332 1 : if (isLoading)
333 1 : const Center(child: LoadingWidget(isLoading: true, size: Size(48, 48)))
334 : else
335 1 : client == null
336 2 : ? FullSizedButtonWidget(
337 : key: const ValueKey('fints_connect'),
338 : icon: Icons.login,
339 1 : onPressed: connect,
340 2 : title: AppLocale.labels.integrationConnect,
341 : )
342 1 : : FullSizedButtonWidget(
343 : key: const ValueKey('fints_disconnect'),
344 : icon: Icons.logout,
345 1 : onPressed: disconnect,
346 2 : title: AppLocale.labels.integrationDisconnect,
347 : ),
348 2 : for (final item in accounts) buildAccount(context, item, state),
349 : ],
350 : );
351 : });
352 : }
353 : }
|