LCOV - code coverage report
Current view: top level - lib/_classes/storage - statement_parser.dart Coverage Total Hit
Test: lcov.info Lines: 65.7 % 67 44
Test Date: 2026-11-14 15:04:37 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              : 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            2 :   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            0 :         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            1 :     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            0 :   static String normalizeMt940(String text) {
      87              :     final field =
      88            0 :         RegExp(r'^:86:(\d{3})([~^])(\d{2})(.*?)(?=^:\d{2}[A-Z]?:|^-\s*$|(?![\s\S]))', multiLine: true, dotAll: true);
      89            0 :     return text.replaceAllMapped(field, (m) {
      90            0 :       final separator = RegExp('\\${m.group(2)}(?=\\d{2})');
      91            0 :       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            4 :     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            0 :     } on FormatException {
     103              :       // Single-byte encoding
     104              :     }
     105            0 :     final head = latin1.decode(content.length > 200 ? content.sublist(0, 200) : content);
     106            0 :     final declared = RegExp('encoding\\s*=\\s*["\']([^"\']+)["\']', caseSensitive: false).firstMatch(head)?.group(1);
     107            0 :     switch (declared?.toLowerCase()) {
     108            0 :       case 'windows-1250' || 'cp1250':
     109            0 :         return _decodeTable(content, _windows1250);
     110            0 :       case 'iso-8859-2' || 'iso8859-2' || 'latin2':
     111            0 :         return _decodeTable(content, _iso88592);
     112            0 :       case 'iso-8859-1' || 'latin1':
     113            0 :         return latin1.decode(content);
     114              :     }
     115            0 :     final windows = _decodeTable(content, _windows1250);
     116            0 :     final iso = _decodeTable(content, _iso88592);
     117            0 :     int score(String text) => text.runes.where(_polishLetters.runes.contains).length;
     118            0 :     return score(iso) > score(windows) ? iso : windows;
     119              :   }
     120              : 
     121            0 :   static String _decodeTable(List<int> bytes, List<int> high) =>
     122            0 :       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              : }
        

Generated by: LCOV version 2.0-1