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 0 : FinTsBankAccount(this.account, {super.appAccount, super.category, required super.from})
34 0 : : key = FinTsSettings.accountKey(account);
35 :
36 0 : String get title {
37 0 : final name = account.productName ?? account.ownerName ?? '';
38 0 : final number = account.iban ?? account.accountNumber ?? '';
39 0 : final masked = number.length > 4 ? '…${number.substring(number.length - 4)}' : number;
40 0 : 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 1 : const FinTsWidget({super.key, this.clientFactory, this.isWeb = kIsWeb});
49 :
50 0 : @override
51 0 : 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 0 : @override
69 : void initState() {
70 0 : super.initState();
71 0 : settings = FinTsSettings.load();
72 0 : dialogs = FinTsDialogs(() => context);
73 0 : url = TextEditingController(text: settings.url);
74 0 : bankCode = TextEditingController(text: settings.bankCode);
75 0 : userId = TextEditingController(text: settings.userId);
76 0 : customerId = TextEditingController(text: settings.customerId);
77 0 : productId = TextEditingController(text: settings.productId);
78 : }
79 :
80 0 : @override
81 : void dispose() {
82 0 : unawaited(_close());
83 0 : for (final controller in [url, bankCode, userId, customerId, productId]) {
84 0 : controller.dispose();
85 : }
86 0 : super.dispose();
87 : }
88 :
89 0 : Future<void> _close() async {
90 0 : final current = client;
91 0 : client = null;
92 : if (current == null) {
93 : return;
94 : }
95 : try {
96 0 : await current.close();
97 : } finally {
98 0 : await FinTsSettings.saveState(current.state);
99 : }
100 : }
101 :
102 0 : FinTsClient _createClient(String pin) {
103 0 : final options = FinTsClientOptions(
104 0 : state: FinTsSettings.loadState(),
105 0 : tanCallback: dialogs.askTan,
106 0 : tanMethodSelector: dialogs.selectTanMethod,
107 0 : tanMediumSelector: dialogs.selectTanMedium,
108 0 : onBankMessage: (message) => _notify('${message.subject}: ${message.text}'),
109 0 : onWarning: (warning) => _notify('${warning.code}: ${warning.text}'),
110 : );
111 0 : 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 0 : Future<void> _run(Future<void> Function() callback) async {
122 0 : setState(() {
123 0 : isLoading = true;
124 0 : error = null;
125 0 : notice = null;
126 : });
127 : try {
128 0 : 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 0 : final current = client;
140 : if (current != null) {
141 0 : await FinTsSettings.saveState(current.state);
142 : }
143 0 : if (mounted) {
144 0 : setState(() => isLoading = false);
145 : }
146 : }
147 : }
148 :
149 0 : Future<void> saveSettings() async {
150 0 : settings
151 0 : ..url = url.text.trim()
152 0 : ..bankCode = bankCode.text.trim()
153 0 : ..userId = userId.text.trim()
154 0 : ..customerId = customerId.text.trim()
155 0 : ..productId = productId.text.trim();
156 0 : await settings.save();
157 : }
158 :
159 0 : Future<void> connect() => _run(() async {
160 0 : await saveSettings();
161 0 : if (!settings.isComplete) {
162 0 : throw ArgumentError(AppLocale.labels.finTsIncomplete);
163 : }
164 0 : settings.toConfig();
165 0 : final pin = await dialogs.askPin();
166 : if (pin == null) {
167 : return;
168 : }
169 0 : await _close();
170 0 : final current = _createClient(pin);
171 0 : final refusal = current.loginRefusal;
172 0 : 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 0 : client = current;
182 0 : final list = await current.getAccounts();
183 0 : final defaultBudget = AppPreferences.get(AppPreferences.prefBudget);
184 0 : final monthAgo = DateTime.now().subtract(const Duration(days: 30));
185 0 : accounts = list.map((account) {
186 0 : final key = FinTsSettings.accountKey(account);
187 0 : return FinTsBankAccount(
188 : account,
189 0 : appAccount: settings.links[key],
190 : category: defaultBudget,
191 0 : from: settings.imported[key] ?? monthAgo,
192 : );
193 0 : }).toList();
194 0 : for (final item in accounts) {
195 : try {
196 0 : item.balance = await current.getBalance(item.account);
197 0 : } on FinTsUnsupportedException {
198 : // The bank offers no balance for this account
199 : }
200 : }
201 0 : if (accounts.isEmpty) {
202 0 : notice = AppLocale.labels.integrationNoAccounts;
203 : }
204 : });
205 :
206 0 : Future<void> disconnect() => _run(() async {
207 0 : await _close();
208 0 : accounts = [];
209 : });
210 :
211 0 : Future<void> importTransactions(FinTsBankAccount item) => _run(() async {
212 0 : final current = client;
213 : if (current == null) {
214 : return;
215 : }
216 0 : if (item.appAccount == null || item.category == null) {
217 0 : throw ArgumentError(AppLocale.labels.integrationSelectTarget);
218 : }
219 0 : final state = Provider.of<AppData>(context, listen: false);
220 : List<Transaction> transactions;
221 : try {
222 0 : 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 0 : final importer = FinTsImporter(
228 0 : exists: (uuid) => state.getByUuid(uuid) != null,
229 0 : add: (value, uuid) => TransactionLog.save(state.add(value, uuid)),
230 : );
231 0 : final result = importer.run(
232 0 : accountKey: item.key,
233 : transactions: transactions,
234 0 : account: item.appAccount!,
235 0 : category: item.category!,
236 : );
237 0 : await state.restate();
238 0 : final today = DateTime.now();
239 0 : settings.links[item.key] = item.appAccount!;
240 0 : settings.imported[item.key] = DateTime(today.year, today.month, today.day);
241 0 : await settings.save();
242 0 : notice = AppLocale.labels.integrationImported(result.added, result.pending, result.skipped);
243 : });
244 :
245 0 : Widget buildSettings(BuildContext context) {
246 0 : final isConnected = client != null;
247 0 : return Column(
248 0 : crossAxisAlignment: AppDesign.getAlignment(),
249 0 : children: [
250 0 : InputWrapper.text(
251 0 : title: AppLocale.labels.finTsUrl,
252 : tooltip: 'https://…',
253 0 : controller: url,
254 : isRequired: true,
255 : isDisabled: isConnected,
256 : inputType: TextInputType.url,
257 : ),
258 0 : InputWrapper.text(
259 0 : title: AppLocale.labels.finTsBankCode,
260 0 : controller: bankCode,
261 : isRequired: true,
262 : isDisabled: isConnected,
263 : inputType: TextInputType.number,
264 : ),
265 0 : InputWrapper.text(
266 0 : title: AppLocale.labels.finTsUserId,
267 0 : controller: userId,
268 : isRequired: true,
269 : isDisabled: isConnected,
270 : ),
271 0 : InputWrapper.text(
272 0 : title: AppLocale.labels.finTsCustomerId,
273 0 : controller: customerId,
274 : isDisabled: isConnected,
275 : ),
276 0 : InputWrapper.text(
277 0 : title: AppLocale.labels.finTsProductId,
278 0 : tooltip: AppLocale.labels.finTsProductIdTooltip,
279 0 : controller: productId,
280 : isRequired: true,
281 : isDisabled: isConnected,
282 : ),
283 0 : InputWrapper.select(
284 0 : title: AppLocale.labels.finTsVersion,
285 0 : value: settings.version,
286 : isDisabled: isConnected,
287 0 : options: <ListSelectorItem>[
288 0 : 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 0 : Widget buildAccount(BuildContext context, FinTsBankAccount item, AppData state) {
297 0 : final balance = item.balance?.booked;
298 0 : return IntegrationAccountCard(
299 0 : title: item.title,
300 0 : subtitle: balance != null ? '${AppLocale.labels.balance}: ${balance.value} ${balance.currency}' : null,
301 : target: item,
302 : state: state,
303 0 : isDisabled: isLoading,
304 0 : onChanged: () => setState(() {}),
305 0 : onImport: () => importTransactions(item),
306 : );
307 : }
308 :
309 0 : @override
310 : Widget build(BuildContext context) {
311 0 : final textTheme = context.textTheme;
312 0 : final colorScheme = context.colorScheme;
313 0 : if (widget.isWeb) {
314 0 : return Text(AppLocale.labels.finTsWebUnsupported, style: textTheme.bodyLarge);
315 : }
316 0 : return Consumer<AppData>(builder: (context, state, _) {
317 0 : return Column(
318 0 : crossAxisAlignment: AppDesign.getAlignment(),
319 0 : children: [
320 0 : Text(AppLocale.labels.finTsDescription, style: textTheme.bodyMedium),
321 : ThemeHelper.hIndent2x,
322 0 : buildSettings(context),
323 : ThemeHelper.hIndent2x,
324 0 : if (error != null) ...[
325 0 : Text(error!, style: textTheme.bodyLarge?.copyWith(color: colorScheme.error)),
326 : ThemeHelper.hIndent,
327 : ],
328 0 : if (notice != null) ...[
329 0 : Text(notice!, style: textTheme.bodyLarge),
330 : ThemeHelper.hIndent,
331 : ],
332 0 : if (isLoading)
333 0 : const Center(child: LoadingWidget(isLoading: true, size: Size(48, 48)))
334 : else
335 0 : client == null
336 0 : ? FullSizedButtonWidget(
337 : key: const ValueKey('fints_connect'),
338 : icon: Icons.login,
339 0 : onPressed: connect,
340 0 : title: AppLocale.labels.integrationConnect,
341 : )
342 0 : : FullSizedButtonWidget(
343 : key: const ValueKey('fints_disconnect'),
344 : icon: Icons.logout,
345 0 : onPressed: disconnect,
346 0 : title: AppLocale.labels.integrationDisconnect,
347 : ),
348 0 : for (final item in accounts) buildAccount(context, item, state),
349 : ],
350 : );
351 : });
352 : }
353 : }
|