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 : import 'dart:typed_data';
6 :
7 : import 'package:app_finance/_classes/herald/app_locale.dart';
8 : import 'package:app_finance/_classes/integration/fints/fints_importer.dart';
9 : import 'package:fints/fints.dart';
10 : import 'package:uuid/uuid.dart';
11 :
12 : // Bank statement files (MT940 / MT942 and ISO 20022 camt.052 / camt.053 / camt.054)
13 : class StatementParser {
14 : static const mt940Format = 'mt940';
15 : static const camtFormat = 'camt.053';
16 : static const formats = [mt940Format, camtFormat];
17 :
18 : static const extensions = {
19 : mt940Format: ['mt940', 'sta', '940', 'txt'],
20 : camtFormat: ['xml'],
21 : };
22 :
23 : static const dateFormat = 'yyyy-MM-dd';
24 :
25 2 : static List<String> get header => [
26 2 : AppLocale.labels.uuid,
27 2 : AppLocale.labels.expense,
28 2 : AppLocale.labels.description,
29 2 : AppLocale.labels.balanceDate,
30 2 : AppLocale.labels.flowTypeTooltip,
31 2 : AppLocale.labels.currency,
32 2 : AppLocale.labels.account,
33 : ];
34 :
35 0 : static bool isStatement(String format) => formats.contains(format);
36 :
37 1 : static ({List<List<dynamic>> rows, int pending}) parse(String format, Uint8List bytes) {
38 1 : final text = decode(bytes);
39 : final statements = switch (format) {
40 1 : mt940Format =>
41 7 : Mt940.parse(normalizeMt940(text)).map((e) => (account: e.accountId, transactions: e.transactions)).toList(),
42 7 : camtFormat => Camt.parse(text).map((e) => (account: e.accountId, transactions: e.transactions)).toList(),
43 0 : _ => throw ArgumentError.value(format, 'format'),
44 : };
45 2 : final result = <List<dynamic>>[header];
46 1 : final occurrences = <String, int>{};
47 : var pending = 0;
48 2 : for (final statement in statements) {
49 3 : final account = statement.account?.replaceAll(RegExp(r'^/'), '').trim() ?? '';
50 2 : for (final transaction in statement.transactions) {
51 1 : if (transaction.isPending) {
52 1 : pending++;
53 : continue;
54 : }
55 1 : final fingerprint = _fingerprint(account, transaction);
56 2 : final index = occurrences.update(fingerprint, (value) => value + 1, ifAbsent: () => 0);
57 1 : final date = transaction.bookingDate ?? transaction.valueDate;
58 2 : result.add([
59 3 : const Uuid().v5(Namespace.url.value, 'statement:$fingerprint#$index'),
60 4 : transaction.amount.value.abs().toString(),
61 1 : FinTsImporter.getTitle(transaction),
62 2 : date.toIso8601String().substring(0, 10),
63 6 : transaction.amount.isNegative ? AppLocale.labels.bill : AppLocale.labels.flowTypeInvoice,
64 2 : transaction.amount.currency,
65 : account,
66 : ]);
67 : }
68 : }
69 : return (rows: result, pending: pending);
70 : }
71 :
72 1 : static String _fingerprint(String account, Transaction transaction) {
73 2 : final reference = transaction.endToEndId ?? transaction.bankReference ?? transaction.customerReference ?? '';
74 1 : return [
75 : account,
76 3 : transaction.valueDate.toIso8601String().substring(0, 10),
77 2 : transaction.amount.minorUnits,
78 2 : transaction.amount.scale,
79 2 : transaction.amount.currency,
80 2 : reference == 'NOTPROVIDED' || reference == 'NONREF' ? '' : reference,
81 1 : transaction.purpose ?? '',
82 1 : ].join('|');
83 : }
84 :
85 : // Polish (ZBP) and other banks structure `:86:` like the German format, with `~` (or `^`) instead of `?`
86 1 : static String normalizeMt940(String text) {
87 : final field =
88 1 : RegExp(r'^:86:(\d{3})([~^])(\d{2})(.*?)(?=^:\d{2}[A-Z]?:|^-\s*$|(?![\s\S]))', multiLine: true, dotAll: true);
89 2 : return text.replaceAllMapped(field, (m) {
90 3 : final separator = RegExp('\\${m.group(2)}(?=\\d{2})');
91 5 : return ':86:${m.group(1)}?${m.group(3)}${m.group(4)!.replaceAll(separator, '?')}';
92 : });
93 : }
94 :
95 : static const _polishLetters = 'ąćęłńóśźżĄĆĘŁŃÓŚŹŻ';
96 :
97 1 : static String decode(Uint8List bytes) {
98 8 : final start = bytes.length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ? 3 : 0;
99 1 : final content = Uint8List.sublistView(bytes, start);
100 : try {
101 1 : return utf8.decode(content);
102 1 : } on FormatException {
103 : // Single-byte encoding
104 : }
105 4 : final head = latin1.decode(content.length > 200 ? content.sublist(0, 200) : content);
106 3 : final declared = RegExp('encoding\\s*=\\s*["\']([^"\']+)["\']', caseSensitive: false).firstMatch(head)?.group(1);
107 1 : switch (declared?.toLowerCase()) {
108 2 : case 'windows-1250' || 'cp1250':
109 0 : return _decodeTable(content, _windows1250);
110 3 : case 'iso-8859-2' || 'iso8859-2' || 'latin2':
111 1 : return _decodeTable(content, _iso88592);
112 2 : case 'iso-8859-1' || 'latin1':
113 0 : return latin1.decode(content);
114 : }
115 1 : final windows = _decodeTable(content, _windows1250);
116 1 : final iso = _decodeTable(content, _iso88592);
117 6 : int score(String text) => text.runes.where(_polishLetters.runes.contains).length;
118 3 : return score(iso) > score(windows) ? iso : windows;
119 : }
120 :
121 1 : static String _decodeTable(List<int> bytes, List<int> high) =>
122 7 : String.fromCharCodes([for (final b in bytes) b < 0x80 ? b : high[b - 0x80]]);
123 :
124 : // Code points of the bytes 0x80..0xFF (generated from the Python codecs `cp1250` and `iso8859_2`)
125 : static const _windows1250 = [
126 : 0x20AC,
127 : 0xFFFD,
128 : 0x201A,
129 : 0xFFFD,
130 : 0x201E,
131 : 0x2026,
132 : 0x2020,
133 : 0x2021,
134 : 0xFFFD,
135 : 0x2030,
136 : 0x0160,
137 : 0x2039,
138 : 0x015A,
139 : 0x0164,
140 : 0x017D,
141 : 0x0179,
142 : 0xFFFD,
143 : 0x2018,
144 : 0x2019,
145 : 0x201C,
146 : 0x201D,
147 : 0x2022,
148 : 0x2013,
149 : 0x2014,
150 : 0xFFFD,
151 : 0x2122,
152 : 0x0161,
153 : 0x203A,
154 : 0x015B,
155 : 0x0165,
156 : 0x017E,
157 : 0x017A,
158 : 0x00A0,
159 : 0x02C7,
160 : 0x02D8,
161 : 0x0141,
162 : 0x00A4,
163 : 0x0104,
164 : 0x00A6,
165 : 0x00A7,
166 : 0x00A8,
167 : 0x00A9,
168 : 0x015E,
169 : 0x00AB,
170 : 0x00AC,
171 : 0x00AD,
172 : 0x00AE,
173 : 0x017B,
174 : 0x00B0,
175 : 0x00B1,
176 : 0x02DB,
177 : 0x0142,
178 : 0x00B4,
179 : 0x00B5,
180 : 0x00B6,
181 : 0x00B7,
182 : 0x00B8,
183 : 0x0105,
184 : 0x015F,
185 : 0x00BB,
186 : 0x013D,
187 : 0x02DD,
188 : 0x013E,
189 : 0x017C,
190 : 0x0154,
191 : 0x00C1,
192 : 0x00C2,
193 : 0x0102,
194 : 0x00C4,
195 : 0x0139,
196 : 0x0106,
197 : 0x00C7,
198 : 0x010C,
199 : 0x00C9,
200 : 0x0118,
201 : 0x00CB,
202 : 0x011A,
203 : 0x00CD,
204 : 0x00CE,
205 : 0x010E,
206 : 0x0110,
207 : 0x0143,
208 : 0x0147,
209 : 0x00D3,
210 : 0x00D4,
211 : 0x0150,
212 : 0x00D6,
213 : 0x00D7,
214 : 0x0158,
215 : 0x016E,
216 : 0x00DA,
217 : 0x0170,
218 : 0x00DC,
219 : 0x00DD,
220 : 0x0162,
221 : 0x00DF,
222 : 0x0155,
223 : 0x00E1,
224 : 0x00E2,
225 : 0x0103,
226 : 0x00E4,
227 : 0x013A,
228 : 0x0107,
229 : 0x00E7,
230 : 0x010D,
231 : 0x00E9,
232 : 0x0119,
233 : 0x00EB,
234 : 0x011B,
235 : 0x00ED,
236 : 0x00EE,
237 : 0x010F,
238 : 0x0111,
239 : 0x0144,
240 : 0x0148,
241 : 0x00F3,
242 : 0x00F4,
243 : 0x0151,
244 : 0x00F6,
245 : 0x00F7,
246 : 0x0159,
247 : 0x016F,
248 : 0x00FA,
249 : 0x0171,
250 : 0x00FC,
251 : 0x00FD,
252 : 0x0163,
253 : 0x02D9,
254 : ];
255 :
256 : static const _iso88592 = [
257 : 0x0080,
258 : 0x0081,
259 : 0x0082,
260 : 0x0083,
261 : 0x0084,
262 : 0x0085,
263 : 0x0086,
264 : 0x0087,
265 : 0x0088,
266 : 0x0089,
267 : 0x008A,
268 : 0x008B,
269 : 0x008C,
270 : 0x008D,
271 : 0x008E,
272 : 0x008F,
273 : 0x0090,
274 : 0x0091,
275 : 0x0092,
276 : 0x0093,
277 : 0x0094,
278 : 0x0095,
279 : 0x0096,
280 : 0x0097,
281 : 0x0098,
282 : 0x0099,
283 : 0x009A,
284 : 0x009B,
285 : 0x009C,
286 : 0x009D,
287 : 0x009E,
288 : 0x009F,
289 : 0x00A0,
290 : 0x0104,
291 : 0x02D8,
292 : 0x0141,
293 : 0x00A4,
294 : 0x013D,
295 : 0x015A,
296 : 0x00A7,
297 : 0x00A8,
298 : 0x0160,
299 : 0x015E,
300 : 0x0164,
301 : 0x0179,
302 : 0x00AD,
303 : 0x017D,
304 : 0x017B,
305 : 0x00B0,
306 : 0x0105,
307 : 0x02DB,
308 : 0x0142,
309 : 0x00B4,
310 : 0x013E,
311 : 0x015B,
312 : 0x02C7,
313 : 0x00B8,
314 : 0x0161,
315 : 0x015F,
316 : 0x0165,
317 : 0x017A,
318 : 0x02DD,
319 : 0x017E,
320 : 0x017C,
321 : 0x0154,
322 : 0x00C1,
323 : 0x00C2,
324 : 0x0102,
325 : 0x00C4,
326 : 0x0139,
327 : 0x0106,
328 : 0x00C7,
329 : 0x010C,
330 : 0x00C9,
331 : 0x0118,
332 : 0x00CB,
333 : 0x011A,
334 : 0x00CD,
335 : 0x00CE,
336 : 0x010E,
337 : 0x0110,
338 : 0x0143,
339 : 0x0147,
340 : 0x00D3,
341 : 0x00D4,
342 : 0x0150,
343 : 0x00D6,
344 : 0x00D7,
345 : 0x0158,
346 : 0x016E,
347 : 0x00DA,
348 : 0x0170,
349 : 0x00DC,
350 : 0x00DD,
351 : 0x0162,
352 : 0x00DF,
353 : 0x0155,
354 : 0x00E1,
355 : 0x00E2,
356 : 0x0103,
357 : 0x00E4,
358 : 0x013A,
359 : 0x0107,
360 : 0x00E7,
361 : 0x010D,
362 : 0x00E9,
363 : 0x0119,
364 : 0x00EB,
365 : 0x011B,
366 : 0x00ED,
367 : 0x00EE,
368 : 0x010F,
369 : 0x0111,
370 : 0x0144,
371 : 0x0148,
372 : 0x00F3,
373 : 0x00F4,
374 : 0x0151,
375 : 0x00F6,
376 : 0x00F7,
377 : 0x0159,
378 : 0x016F,
379 : 0x00FA,
380 : 0x0171,
381 : 0x00FC,
382 : 0x00FD,
383 : 0x0163,
384 : 0x02D9,
385 : ];
386 : }
|