diff --git a/README.md b/README.md index defa3a0..3e6ffa2 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,37 @@ transactions = mt940.parse('mt940_tests/jejik/abnamro.sta') print(json.dumps(transactions, indent=4, cls=mt940.JSONEncoder)) ``` +### Reading balances + +Statement-level balances live on the `Transactions` object's `data`, not on the +individual transactions — this works even for files with no transactions at all: + +```python +import mt940 + +transactions = mt940.parse('statement.sta') +print(transactions.data['final_opening_balance']) +print(transactions.data['final_closing_balance']) +print(transactions.data['available_balance']) +``` + +### Multiple statements in one file + +A single `parse()` merges everything into one `Transactions` and keeps only the +**last** block's statement-level data (e.g. balances). For files that concatenate +several statements (including balance-only blocks), use `parse_statements()`, +which splits on `:20:` boundaries and returns one `Transactions` per statement, +each with its own balances: + +```python +import mt940 + +# src may be a filename, a file handle or the raw data, just like parse() +for statement in mt940.parse_statements('statements.sta'): + print(statement.data['final_opening_balance']) + print(statement.data['final_closing_balance']) +``` + ### Parsing statements from the Dutch bank ASN Tag 61 in ASN statements does not follow the SWIFT specification, so a custom diff --git a/mt940/__init__.py b/mt940/__init__.py index 8e96db4..2e2ff6c 100644 --- a/mt940/__init__.py +++ b/mt940/__init__.py @@ -1,7 +1,7 @@ from . import json, models, parser, processors, tags, utils from .__about__ import __version__ from .json import JSONEncoder -from .parser import parse +from .parser import parse, parse_statements __all__ = [ 'JSONEncoder', @@ -9,6 +9,7 @@ 'json', 'models', 'parse', + 'parse_statements', 'parser', 'processors', 'tags', diff --git a/mt940/parser.py b/mt940/parser.py index f4824b2..0d8e4e8 100644 --- a/mt940/parser.py +++ b/mt940/parser.py @@ -27,6 +27,7 @@ from __future__ import annotations import os +import re from typing import TYPE_CHECKING, Any import mt940 @@ -35,27 +36,8 @@ from .models import Transactions -def parse( - src: Any, - encoding: str | None = None, - processors: dict[str, list[Any]] | None = None, - tags: dict[Any, Any] | None = None, - transaction_boundary: Any = None, -) -> Transactions: - """ - Parses mt940 data and returns transactions object - - :param src: file handler to read, filename to read or raw data as string - :param encoding: optional encoding override for byte input - :param processors: optional extra pre/post processors - :param tags: optional extra/override tag parsers - :param transaction_boundary: optional iterable of tag *slugs* that each - start a new transaction (issue #110). By default only ``:61:`` starts a - transaction; pass e.g. ``{'transaction_reference_number'}`` to also - start one on every ``:20:``. Omitting it keeps the legacy behaviour. - :return: Collection of transactions - :rtype: Transactions - """ +def _read(src: Any, encoding: str | None = None) -> str: + """Read raw mt940 data from a file handle, path or string and decode it.""" def safe_is_file(filename: Any) -> bool: try: @@ -91,9 +73,82 @@ def safe_is_file(filename: Any) -> bool: raise exception # pragma: no cover assert isinstance(data, str) + return data + + +def parse( + src: Any, + encoding: str | None = None, + processors: dict[str, list[Any]] | None = None, + tags: dict[Any, Any] | None = None, + transaction_boundary: Any = None, +) -> Transactions: + """ + Parses mt940 data and returns transactions object + + :param src: file handler to read, filename to read or raw data as string + :param encoding: optional encoding override for byte input + :param processors: optional extra pre/post processors + :param tags: optional extra/override tag parsers + :param transaction_boundary: optional iterable of tag *slugs* that each + start a new transaction (issue #110). By default only ``:61:`` starts a + transaction; pass e.g. ``{'transaction_reference_number'}`` to also + start one on every ``:20:``. Omitting it keeps the legacy behaviour. + :return: Collection of transactions + :rtype: Transactions + """ + data = _read(src, encoding) transactions = mt940.models.Transactions( processors, tags, transaction_boundary=transaction_boundary ) transactions.parse(data) return transactions + + +def parse_statements( + src: Any, + encoding: str | None = None, + processors: dict[str, list[Any]] | None = None, + tags: dict[Any, Any] | None = None, + transaction_boundary: Any = None, +) -> list[Transactions]: + """ + Parse an mt940 file that contains multiple statement blocks. + + Unlike :func:`parse`, which merges everything into a single + :class:`~mt940.models.Transactions`, this splits the input on ``:20:`` + statement boundaries and parses each block into its own + :class:`~mt940.models.Transactions`. Use it for files that concatenate + several statements (e.g. balance-only blocks), where a single + ``Transactions`` would only keep the last block's statement-level data such + as the opening/closing/available balances (issue #107). + + Each ``:20:`` is treated as the start of a new statement, matching the + standard where ``:20:`` is the once-per-statement transaction reference. + This is therefore mutually exclusive with + ``transaction_boundary={'transaction_reference_number'}`` (issue #110), + which instead treats ``:20:`` as an *intra*-statement transaction boundary; + the two target different, non-standard bank formats -- don't combine them. + + :param src: file handler to read, filename to read or raw data as string + :param encoding: optional encoding override for byte input + :param processors: optional extra pre/post processors (applied per block) + :param tags: optional extra/override tag parsers (applied per block) + :param transaction_boundary: see :func:`parse` (see the note above) + :return: one Transactions per statement block + :rtype: list[Transactions] + """ + data = _read(src, encoding) + statements: list[Transactions] = [] + for block in re.split(r'(?m)^(?=:20:)', data): + if not block.strip().startswith(':20:'): + # Drop any leading header / empty chunk before the first :20:. + continue + transactions = mt940.models.Transactions( + processors, tags, transaction_boundary=transaction_boundary + ) + transactions.parse(block) + statements.append(transactions) + + return statements diff --git a/mt940_tests/test_issues/test_issue107_statements.py b/mt940_tests/test_issues/test_issue107_statements.py new file mode 100644 index 0000000..295cf6d --- /dev/null +++ b/mt940_tests/test_issues/test_issue107_statements.py @@ -0,0 +1,91 @@ +"""Issue #107: balance-only / multi-statement MT940 files. + +A single ``mt940.parse()`` merges everything into one ``Transactions`` and +keeps only the last block's statement-level data (balances). +``parse_statements()`` +splits the input on ``:20:`` boundaries so each statement block keeps its own +balances. The default ``parse()`` behaviour is unchanged. +""" + +import mt940 + +MULTI_BLOCK = """:20:REF1 +:25:ACC1 +:28:1/1 +:60F:C231231EUR100,00 +:62F:C231231EUR111,00 +- +:20:REF2 +:25:ACC2 +:28:1/1 +:60F:C231231EUR200,00 +:62F:C231231EUR222,00 +- +""" + + +def test_multi_block_balance_only_keeps_every_block(): + statements = mt940.parse_statements(MULTI_BLOCK) + assert len(statements) == 2 + assert str(statements[0].data['final_opening_balance']).startswith( + '100.00' + ) + assert str(statements[0].data['final_closing_balance']).startswith( + '111.00' + ) + assert str(statements[1].data['final_opening_balance']).startswith( + '200.00' + ) + assert str(statements[1].data['final_closing_balance']).startswith( + '222.00' + ) + + +def test_default_parse_unchanged_for_multi_block(): + # The default parser still merges and keeps only the last block's balance. + transactions = mt940.parse(MULTI_BLOCK) + assert len(transactions) == 0 + assert str(transactions.data['final_opening_balance']).startswith('200.00') + + +def test_single_statement_returns_one(): + data = """:20:REF +:25:NL00BANK0123456789EUR +:28:1/1 +:60F:C231231EUR1000,00 +:62F:C231231EUR1500,00 +:64:C231231EUR1500,00 +- +""" + statements = mt940.parse_statements(data) + assert len(statements) == 1 + assert str(statements[0].data['final_opening_balance']).startswith( + '1000.00' + ) + assert str(statements[0].data['available_balance']).startswith('1500.00') + + +def test_abnamro_file_splits_into_statements(): + # abnamro.sta has two :20: blocks; parse_statements separates them while + # the default parse() still merges them into one collection. + path = 'mt940_tests/jejik/abnamro.sta' + statements = mt940.parse_statements(path) + assert len(statements) == 2 + assert len(mt940.parse(path)) == 10 + + +def test_kwargs_passed_through_to_each_block(): + gls = mt940.tags.StatementGLS() + data = ( + ':20:R\n:25:ACC\n:60F:C220706EUR0,00\n' + ':61:2207060706DR20,NTRFBIPI-dvT1FzfMqvzF5HaU4oetlH7SGRkonU' + '//2022070616391534000\n' + ':86:116?00x\n:62F:C220706EUR0,00\n' + ) + statements = mt940.parse_statements(data, tags={gls.id: gls}) + assert len(statements) == 1 + transaction = statements[0].transactions[0] + assert ( + transaction.data['customer_reference'] + == 'BIPI-dvT1FzfMqvzF5HaU4oetlH7SGRkonU' + )