Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion mt940/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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',
'__version__',
'json',
'models',
'parse',
'parse_statements',
'parser',
'processors',
'tags',
Expand Down
97 changes: 76 additions & 21 deletions mt940/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from __future__ import annotations

import os
import re
from typing import TYPE_CHECKING, Any

import mt940
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Comment thread
wolph marked this conversation as resolved.
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
91 changes: 91 additions & 0 deletions mt940_tests/test_issues/test_issue107_statements.py
Original file line number Diff line number Diff line change
@@ -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'
)