bankstatementparser-loader-mt942 0.0.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ # Copyright (C) 2023-2026 Sebastien Rousseau.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12
+ # implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """bankstatementparser-loader-mt942: SWIFT MT942 loader.
17
+
18
+ A focused companion to the
19
+ `bankstatementparser <https://github.com/sebastienrousseau/bankstatementparser>`_
20
+ library that parses SWIFT **MT942 Interim Transaction Report** files —
21
+ a format the core library does not support — into
22
+ :class:`bankstatementparser.transaction_models.Transaction` objects.
23
+ """
24
+
25
+ from bankstatementparser_loader_mt942.loader import (
26
+ Mt942Summary,
27
+ load_mt942,
28
+ load_mt942_file,
29
+ summarize_mt942,
30
+ )
31
+
32
+ __all__ = [
33
+ "Mt942Summary",
34
+ "load_mt942",
35
+ "load_mt942_file",
36
+ "summarize_mt942",
37
+ ]
38
+
39
+ __version__ = "0.0.10"
@@ -0,0 +1,535 @@
1
+ # Copyright (C) 2023-2026 Sebastien Rousseau.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12
+ # implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """SWIFT MT942 Interim Transaction Report loader.
17
+
18
+ The core :mod:`bankstatementparser` library parses PDF and CSV bank
19
+ statements but does **not** understand the SWIFT MT942 *Interim
20
+ Transaction Report* wire format. This module fills that gap: it parses
21
+ the MT942 tag grammar and hands back the same
22
+ :class:`bankstatementparser.transaction_models.Transaction` objects the
23
+ core library produces, so MT942 feeds drop straight into any
24
+ downstream consumer (deduplication, categorisation, exports).
25
+
26
+ MT942 is an *interim* statement: unlike MT940 it carries **no**
27
+ ``:60F:`` opening or ``:62F:`` closing balance. Instead it reports a
28
+ floor limit (``:34F:``), an optional date/time stamp (``:13D:``), the
29
+ booked statement lines (``:61:`` / ``:86:``) accumulated so far, and
30
+ debit/credit summaries (``:90D:`` / ``:90C:``). This loader models that
31
+ structure faithfully and never invents balances that are not present in
32
+ the source.
33
+
34
+ Supported tags:
35
+
36
+ * ``:20:`` Transaction reference number (mandatory).
37
+ * ``:25:`` Account identification (mandatory) — the account id.
38
+ * ``:28C:`` Statement / sequence number.
39
+ * ``:34F:`` Floor limit indicator ``<CCY>[D|C]<amount>`` — the
40
+ currency for every transaction is taken from here.
41
+ * ``:13D:`` Date/time stamp ``YYMMDDHHMM±HHMM`` (optional).
42
+ * ``:61:`` Statement line (one per booked entry, repeatable).
43
+ * ``:86:`` Information to account owner — attaches its free-form
44
+ description to the immediately preceding ``:61:`` line.
45
+ * ``:90D:`` Debit summary ``<count><CCY><amount>``.
46
+ * ``:90C:`` Credit summary ``<count><CCY><amount>``.
47
+
48
+ Amounts use the SWIFT comma decimal separator (``500,00``); they are
49
+ converted to :class:`decimal.Decimal` (``Decimal("500.00")``)
50
+ throughout. Debit lines yield a negative amount, credit lines a
51
+ positive amount. Unknown tags and the trailing ``-`` end-of-message
52
+ marker are tolerated and ignored.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import os
58
+ import re
59
+ from collections.abc import Iterator
60
+ from dataclasses import dataclass, field
61
+ from datetime import date, datetime, timedelta, timezone
62
+ from decimal import Decimal
63
+
64
+ from bankstatementparser.transaction_models import Transaction
65
+
66
+ __all__ = [
67
+ "Mt942Summary",
68
+ "load_mt942",
69
+ "load_mt942_file",
70
+ "summarize_mt942",
71
+ ]
72
+
73
+ #: The :class:`~bankstatementparser.transaction_models.Transaction`
74
+ #: ``source`` tag stamped on every row this loader produces.
75
+ SOURCE = "mt942"
76
+
77
+
78
+ # ─── Regex helpers ───────────────────────────────────────────────────────────
79
+
80
+ # A field starts with ``:tag:`` at the beginning of a line. Tags are
81
+ # 2-3 chars, optionally followed by a single letter (e.g. 28C, 34F).
82
+ _FIELD_HEAD_RE = re.compile(r"^:(\d{2}[A-Z]?):", re.MULTILINE)
83
+
84
+ # :34F:EURD500,00 / :34F:EUR500,00
85
+ # ^CCY ^DC(opt) ^Amount (comma-decimal)
86
+ _FLOOR_LIMIT_RE = re.compile(
87
+ r"^(?P<ccy>[A-Z]{3})(?P<dc>[DC])?(?P<amt>[\d,]+)$"
88
+ )
89
+
90
+ # :13D:2506241200+0100
91
+ # ^YYMMDD ^HHMM ^±HHMM
92
+ _DATETIME_RE = re.compile(
93
+ r"^(?P<date>\d{6})(?P<time>\d{4})(?P<sign>[+-])(?P<offset>\d{4})$"
94
+ )
95
+
96
+ # :61:2506240624C500,00NTRFREF//BANKREF
97
+ # ^vYYMMDD ^eMMDD(opt) ^DC ^Amt(comma-decimal) ^rest
98
+ # Same grammar as MT940 statement lines.
99
+ _LINE_RE = re.compile(
100
+ r"^(?P<vdate>\d{6})"
101
+ r"(?P<edate>\d{4})?"
102
+ r"(?P<dc>[CD])"
103
+ r"(?P<amt>[\d,]+)"
104
+ r"(?P<rest>.*)$"
105
+ )
106
+
107
+ # :90D:5EUR1234,56 / :90C:3EUR987,65
108
+ # ^count ^CCY ^Amount (comma-decimal)
109
+ _SUMMARY_RE = re.compile(r"^(?P<count>\d+)(?P<ccy>[A-Z]{3})(?P<amt>[\d,]+)$")
110
+
111
+
112
+ # ─── Summary model ───────────────────────────────────────────────────────────
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Mt942Summary:
117
+ """Header-level summary of a parsed MT942 message.
118
+
119
+ Captures the metadata and the debit/credit roll-ups that an MT942
120
+ carries in its own right, independent of the individual
121
+ transactions. Every field maps directly to a source tag; nothing is
122
+ derived or invented.
123
+
124
+ Attributes:
125
+ reference: The ``:20:`` transaction reference number.
126
+ account_id: The ``:25:`` account identification.
127
+ currency: The currency from the ``:34F:`` floor limit, or
128
+ ``None`` if no floor limit was present.
129
+ statement_datetime: The ``:13D:`` date/time stamp as a
130
+ timezone-aware :class:`datetime.datetime`, or ``None`` when
131
+ the optional tag is absent.
132
+ debit_count: The number of debit entries from ``:90D:``, or
133
+ ``None`` when the tag is absent.
134
+ debit_sum: The summed debit amount from ``:90D:`` as a
135
+ :class:`decimal.Decimal`, or ``None`` when absent.
136
+ credit_count: The number of credit entries from ``:90C:``, or
137
+ ``None`` when the tag is absent.
138
+ credit_sum: The summed credit amount from ``:90C:`` as a
139
+ :class:`decimal.Decimal`, or ``None`` when absent.
140
+ transaction_count: The number of ``:61:`` statement lines
141
+ actually parsed from the message.
142
+ """
143
+
144
+ reference: str
145
+ account_id: str
146
+ currency: str | None
147
+ statement_datetime: datetime | None
148
+ debit_count: int | None
149
+ debit_sum: Decimal | None
150
+ credit_count: int | None
151
+ credit_sum: Decimal | None
152
+ transaction_count: int
153
+
154
+
155
+ # ─── Tokeniser ──────────────────────────────────────────────────────────────
156
+
157
+
158
+ def _iter_fields(text: str) -> Iterator[tuple[str, str]]:
159
+ """Yield ``(tag, value)`` pairs from an MT942 payload.
160
+
161
+ Values may span multiple lines: everything after a ``:tag:`` head
162
+ up to (but not including) the next ``:tag:`` head is the value, with
163
+ the leading tag stripped and surrounding whitespace normalised. The
164
+ trailing ``-`` end-of-message marker and blank lines are absorbed
165
+ into (and stripped from) the preceding value, so they never produce
166
+ a spurious field.
167
+
168
+ Args:
169
+ text: The raw MT942 payload.
170
+
171
+ Yields:
172
+ ``(tag, value)`` tuples in document order, where ``tag`` is the
173
+ bare tag such as ``"20"`` or ``"34F"``.
174
+ """
175
+ matches = list(_FIELD_HEAD_RE.finditer(text))
176
+ for index, match in enumerate(matches):
177
+ tag = match.group(1)
178
+ value_start = match.end()
179
+ value_end = (
180
+ matches[index + 1].start()
181
+ if index + 1 < len(matches)
182
+ else len(text)
183
+ )
184
+ value = text[value_start:value_end].strip()
185
+ # Drop a trailing end-of-message marker on the final field.
186
+ if value.endswith("\n-"):
187
+ value = value[:-2].strip()
188
+ elif value == "-":
189
+ value = ""
190
+ yield tag, value
191
+
192
+
193
+ # ─── Field parsers ──────────────────────────────────────────────────────────
194
+
195
+
196
+ def _comma_decimal(value: str) -> Decimal:
197
+ """Convert a SWIFT comma-decimal amount to a :class:`Decimal`.
198
+
199
+ SWIFT uses a comma as the decimal separator (``500,00``); this
200
+ swaps it for a period before constructing the
201
+ :class:`decimal.Decimal` so arithmetic and formatting behave as
202
+ expected.
203
+
204
+ Args:
205
+ value: The amount string, e.g. ``"500,00"``.
206
+
207
+ Returns:
208
+ The equivalent :class:`decimal.Decimal`, e.g.
209
+ ``Decimal("500.00")``.
210
+ """
211
+ return Decimal(value.replace(",", "."))
212
+
213
+
214
+ def _format_yymmdd(value: str) -> date:
215
+ """Parse a 6-char ``YYMMDD`` date into a :class:`datetime.date`.
216
+
217
+ Years are interpreted with a sliding window: ``00``-``79`` map to
218
+ ``20YY`` and ``80``-``99`` to ``19YY``, matching MT940/MT942
219
+ industry practice for any real statement date in the 1980-2079
220
+ range.
221
+
222
+ Args:
223
+ value: A 6-character ``YYMMDD`` string.
224
+
225
+ Returns:
226
+ The corresponding :class:`datetime.date`.
227
+ """
228
+ year = int(value[0:2])
229
+ century = 2000 if year < 80 else 1900
230
+ return date(century + year, int(value[2:4]), int(value[4:6]))
231
+
232
+
233
+ def _parse_datetime_stamp(value: str) -> datetime | None:
234
+ """Parse a ``:13D:`` ``YYMMDDHHMM±HHMM`` stamp.
235
+
236
+ Args:
237
+ value: The ``:13D:`` field value, e.g. ``"2506241200+0100"``.
238
+
239
+ Returns:
240
+ A timezone-aware :class:`datetime.datetime`, or ``None`` if the
241
+ value does not match the expected grammar (tolerated rather
242
+ than fatal, since ``:13D:`` is optional).
243
+ """
244
+ match = _DATETIME_RE.match(value)
245
+ if match is None:
246
+ return None
247
+ day = _format_yymmdd(match.group("date"))
248
+ hours = int(match.group("time")[0:2])
249
+ minutes = int(match.group("time")[2:4])
250
+ offset_hours = int(match.group("offset")[0:2])
251
+ offset_minutes = int(match.group("offset")[2:4])
252
+ sign = 1 if match.group("sign") == "+" else -1
253
+ tzinfo = timezone(
254
+ sign * timedelta(hours=offset_hours, minutes=offset_minutes)
255
+ )
256
+ return datetime(
257
+ day.year, day.month, day.day, hours, minutes, tzinfo=tzinfo
258
+ )
259
+
260
+
261
+ def _entry_dates(vdate: str, edate: str | None) -> tuple[date, date | None]:
262
+ """Resolve the value and entry dates from a ``:61:`` line.
263
+
264
+ The value date is a full 6-char ``YYMMDD``; the optional entry
265
+ (booking) date is a 4-char ``MMDD`` that inherits its year from the
266
+ value date.
267
+
268
+ Args:
269
+ vdate: The 6-character value date (``YYMMDD``).
270
+ edate: The optional 4-character entry date (``MMDD``), or
271
+ ``None``.
272
+
273
+ Returns:
274
+ A ``(value_date, booking_date)`` tuple where ``booking_date`` is
275
+ ``None`` when no entry date was present.
276
+ """
277
+ value_date = _format_yymmdd(vdate)
278
+ if edate is None:
279
+ return value_date, None
280
+ booking_date = _format_yymmdd(vdate[0:2] + edate)
281
+ return value_date, booking_date
282
+
283
+
284
+ def _split_reference(rest: str) -> tuple[str | None, str | None]:
285
+ """Split the ``:61:`` tail into a transaction id and a reference.
286
+
287
+ The tail of a statement line carries the transaction type
288
+ identification code and reference(s), optionally split on ``//``
289
+ into a bank reference and a customer/account-servicer reference.
290
+
291
+ Args:
292
+ rest: The text following the amount on a ``:61:`` line.
293
+
294
+ Returns:
295
+ A ``(transaction_id, reference)`` tuple. ``transaction_id`` is
296
+ the bank reference (left of ``//``) and ``reference`` the
297
+ customer reference (right of ``//``); either may be ``None``.
298
+ """
299
+ bank_ref, sep, customer_ref = rest.partition("//")
300
+ transaction_id = bank_ref.strip() or None
301
+ reference = customer_ref.strip() if sep else None
302
+ return transaction_id, (reference or None)
303
+
304
+
305
+ def _parse_summary(value: str, tag: str) -> tuple[int, Decimal]:
306
+ """Parse a ``:90D:`` / ``:90C:`` summary field.
307
+
308
+ Args:
309
+ value: The summary field value, e.g. ``"5EUR1234,56"``.
310
+ tag: The originating tag (``"90D"`` or ``"90C"``), used only to
311
+ build a clear error message.
312
+
313
+ Returns:
314
+ A ``(count, amount)`` tuple.
315
+
316
+ Raises:
317
+ ValueError: If the field does not match the expected
318
+ ``<count><CCY><amount>`` grammar.
319
+ """
320
+ match = _SUMMARY_RE.match(value)
321
+ if match is None:
322
+ raise ValueError(f"Malformed :{tag}: summary field {value!r}")
323
+ return int(match.group("count")), _comma_decimal(match.group("amt"))
324
+
325
+
326
+ # ─── Internal accumulation ───────────────────────────────────────────────────
327
+
328
+
329
+ @dataclass
330
+ class _State:
331
+ """Mutable accumulator threaded through the field loop.
332
+
333
+ Attributes:
334
+ reference: The ``:20:`` reference, if seen.
335
+ account_id: The ``:25:`` account id, if seen.
336
+ currency: The ``:34F:`` currency, if seen.
337
+ statement_datetime: The ``:13D:`` stamp, if seen.
338
+ debit_count: The ``:90D:`` count, if seen.
339
+ debit_sum: The ``:90D:`` summed amount, if seen.
340
+ credit_count: The ``:90C:`` count, if seen.
341
+ credit_sum: The ``:90C:`` summed amount, if seen.
342
+ records: The accumulated per-transaction field dictionaries.
343
+ """
344
+
345
+ reference: str | None = None
346
+ account_id: str | None = None
347
+ currency: str | None = None
348
+ statement_datetime: datetime | None = None
349
+ debit_count: int | None = None
350
+ debit_sum: Decimal | None = None
351
+ credit_count: int | None = None
352
+ credit_sum: Decimal | None = None
353
+ records: list[dict[str, object]] = field(default_factory=list)
354
+
355
+
356
+ def _parse_line(state: _State, value: str) -> None:
357
+ """Parse a ``:61:`` statement line and append it to ``state``.
358
+
359
+ A malformed ``:61:`` line is **skipped** (not fatal): the MT942 may
360
+ still carry well-formed lines we want, and a single bad row should
361
+ not abort the whole parse.
362
+
363
+ Args:
364
+ state: The accumulator to append the parsed record to.
365
+ value: The ``:61:`` field value.
366
+ """
367
+ match = _LINE_RE.match(value.replace("\n", ""))
368
+ if match is None:
369
+ return
370
+ value_date, booking_date = _entry_dates(
371
+ match.group("vdate"), match.group("edate")
372
+ )
373
+ amount = _comma_decimal(match.group("amt"))
374
+ if match.group("dc") == "D":
375
+ amount = -amount
376
+ transaction_id, reference = _split_reference(match.group("rest") or "")
377
+ state.records.append(
378
+ {
379
+ "amount": amount,
380
+ "value_date": value_date,
381
+ "booking_date": booking_date,
382
+ "transaction_id": transaction_id,
383
+ "reference": reference,
384
+ "description": None,
385
+ }
386
+ )
387
+
388
+
389
+ def _handle_field(state: _State, tag: str, value: str) -> None:
390
+ """Dispatch a single ``(tag, value)`` pair into ``state``.
391
+
392
+ Args:
393
+ state: The accumulator to mutate.
394
+ tag: The bare MT942 tag, e.g. ``"20"`` or ``"90D"``.
395
+ value: The field value (already whitespace-stripped).
396
+ """
397
+ if tag == "20":
398
+ state.reference = value or None
399
+ elif tag == "25":
400
+ state.account_id = value or None
401
+ elif tag == "34F":
402
+ match = _FLOOR_LIMIT_RE.match(value)
403
+ if match is not None and state.currency is None:
404
+ state.currency = match.group("ccy")
405
+ elif tag == "13D":
406
+ state.statement_datetime = _parse_datetime_stamp(value)
407
+ elif tag == "61":
408
+ _parse_line(state, value)
409
+ elif tag == "86":
410
+ if state.records:
411
+ state.records[-1]["description"] = value
412
+ elif tag == "90D":
413
+ state.debit_count, state.debit_sum = _parse_summary(value, "90D")
414
+ elif tag == "90C":
415
+ state.credit_count, state.credit_sum = _parse_summary(value, "90C")
416
+ # :28C: and any unknown tag are ignored (Postel's law).
417
+
418
+
419
+ def _accumulate(text: str) -> _State:
420
+ """Tokenise an MT942 payload and accumulate parsed state.
421
+
422
+ Args:
423
+ text: The raw MT942 payload.
424
+
425
+ Returns:
426
+ The populated :class:`_State`.
427
+
428
+ Raises:
429
+ ValueError: If the mandatory ``:20:`` or ``:25:`` field is
430
+ missing or empty.
431
+ """
432
+ state = _State()
433
+ for tag, value in _iter_fields(text):
434
+ _handle_field(state, tag, value)
435
+ if state.reference is None:
436
+ raise ValueError("MT942 payload missing required :20: reference")
437
+ if state.account_id is None:
438
+ raise ValueError(
439
+ "MT942 payload missing required :25: account identification"
440
+ )
441
+ return state
442
+
443
+
444
+ # ─── Public API ──────────────────────────────────────────────────────────────
445
+
446
+
447
+ def load_mt942(text: str) -> list[Transaction]:
448
+ """Parse an MT942 payload into a list of transactions.
449
+
450
+ Each ``:61:`` statement line becomes one
451
+ :class:`~bankstatementparser.transaction_models.Transaction` with
452
+ ``source="mt942"``. Debit lines carry a negative amount, credit
453
+ lines a positive amount. The account id (``:25:``) and currency
454
+ (``:34F:``) are applied to every transaction; the ``:86:``
455
+ description is attached to its preceding ``:61:`` line.
456
+
457
+ Args:
458
+ text: The MT942 payload as a string. CRLF/LF differences, blank
459
+ lines, and the trailing ``-`` end-of-message marker are
460
+ tolerated; unknown tags are ignored.
461
+
462
+ Returns:
463
+ A list of parsed transactions in document order. Malformed
464
+ ``:61:`` lines are skipped, so the list length may be shorter
465
+ than the number of ``:61:`` tags present.
466
+
467
+ Raises:
468
+ ValueError: If the payload is missing the mandatory ``:20:`` or
469
+ ``:25:`` field.
470
+ """
471
+ state = _accumulate(text)
472
+ transactions: list[Transaction] = []
473
+ for index, record in enumerate(state.records):
474
+ enriched: dict[str, object] = dict(record)
475
+ enriched["account_id"] = state.account_id
476
+ enriched["currency"] = state.currency
477
+ transactions.append(
478
+ Transaction.from_record(
479
+ enriched, source=SOURCE, source_index=index
480
+ )
481
+ )
482
+ return transactions
483
+
484
+
485
+ def load_mt942_file(path: str | os.PathLike[str]) -> list[Transaction]:
486
+ """Read an MT942 file from disk and parse it.
487
+
488
+ Args:
489
+ path: Filesystem path to a UTF-8 encoded MT942 file.
490
+
491
+ Returns:
492
+ A list of parsed transactions, identical to calling
493
+ :func:`load_mt942` on the file's contents.
494
+
495
+ Raises:
496
+ ValueError: If the payload is missing the mandatory ``:20:`` or
497
+ ``:25:`` field.
498
+ OSError: If the file cannot be read.
499
+ """
500
+ with open(path, encoding="utf-8") as handle:
501
+ return load_mt942(handle.read())
502
+
503
+
504
+ def summarize_mt942(text: str) -> Mt942Summary:
505
+ """Parse an MT942 payload into a header-level summary.
506
+
507
+ Unlike :func:`load_mt942`, this returns the message metadata and the
508
+ ``:90D:`` / ``:90C:`` roll-ups rather than the individual
509
+ transactions, while still counting the ``:61:`` lines that parsed.
510
+
511
+ Args:
512
+ text: The MT942 payload as a string.
513
+
514
+ Returns:
515
+ An :class:`Mt942Summary` describing the message.
516
+
517
+ Raises:
518
+ ValueError: If the payload is missing the mandatory ``:20:`` or
519
+ ``:25:`` field.
520
+ """
521
+ state = _accumulate(text)
522
+ # ``_accumulate`` guarantees these are populated.
523
+ assert state.reference is not None
524
+ assert state.account_id is not None
525
+ return Mt942Summary(
526
+ reference=state.reference,
527
+ account_id=state.account_id,
528
+ currency=state.currency,
529
+ statement_datetime=state.statement_datetime,
530
+ debit_count=state.debit_count,
531
+ debit_sum=state.debit_sum,
532
+ credit_count=state.credit_count,
533
+ credit_sum=state.credit_sum,
534
+ transaction_count=len(state.records),
535
+ )
@@ -0,0 +1,283 @@
1
+ Metadata-Version: 2.4
2
+ Name: bankstatementparser-loader-mt942
3
+ Version: 0.0.10
4
+ Summary: SWIFT MT942 Interim Transaction Report loader for the bankstatementparser library — parses MT942 into bankstatementparser Transaction objects.
5
+ License: Apache-2.0
6
+ License-File: LICENSE
7
+ Author: Sebastien Rousseau
8
+ Author-email: sebastian.rousseau@gmail.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: bankstatementparser (>=0.0.9)
18
+ Project-URL: Homepage, https://bankstatementparser.com
19
+ Project-URL: Repository, https://github.com/sebastienrousseau/bankstatementparser-loader-mt942
20
+ Description-Content-Type: text/markdown
21
+
22
+ # bankstatementparser-loader-mt942: SWIFT MT942 loader
23
+
24
+ [![PyPI Version][pypi-badge]][pypi-url]
25
+ [![Python Versions][python-versions-badge]][pypi-url]
26
+ [![License][license-badge]][license-url]
27
+ [![Tests][tests-badge]][tests-url]
28
+ [![Quality][quality-badge]][tests-url]
29
+
30
+ **Parse SWIFT MT942 _Interim Transaction Report_ files into
31
+ [`bankstatementparser`][core] `Transaction` objects.** A single
32
+ `load_mt942(text)` call returns a list of
33
+ `bankstatementparser.transaction_models.Transaction`, ready for every
34
+ downstream consumer that already works with the core library's parser
35
+ output (deduplication, categorisation, exports).
36
+
37
+ > The core [`bankstatementparser`][core] library parses PDF and CSV
38
+ > statements but does **not** understand the SWIFT MT942 wire format.
39
+ > This loader fills that gap without changing the core data model.
40
+
41
+ ## Contents
42
+
43
+ - [Overview](#overview)
44
+ - [Install](#install)
45
+ - [Quick Start](#quick-start)
46
+ - [Supported Fields](#supported-fields)
47
+ - [Field Mapping](#field-mapping)
48
+ - [Summaries](#summaries)
49
+ - [Errors](#errors)
50
+ - [Examples](#examples)
51
+ - [When not to use this loader](#when-not-to-use-this-loader)
52
+ - [Development](#development)
53
+ - [Security](#security)
54
+ - [Documentation](#documentation)
55
+ - [License](#license)
56
+ - [Contributing](#contributing)
57
+ - [Acknowledgements](#acknowledgements)
58
+
59
+ ## Overview
60
+
61
+ `bankstatementparser-loader-mt942` is a small, focused companion to the
62
+ [`bankstatementparser`][core] library. It does one thing well: parse the
63
+ SWIFT MT942 _Interim Transaction Report_ grammar and hand back the same
64
+ `Transaction` objects the core PDF/CSV parsers produce. Every
65
+ transaction is stamped with `source="mt942"` so you can tell where it
66
+ came from.
67
+
68
+ MT942 is an **interim** statement. Unlike MT940 it carries **no** `:60F:`
69
+ opening or `:62F:` closing balance — it reports a floor limit, an
70
+ optional date/time stamp, the statement lines accumulated so far, and
71
+ debit/credit summaries. This loader models that structure faithfully and
72
+ never invents balances that are not present in the source.
73
+
74
+ ## Install
75
+
76
+ `bankstatementparser-loader-mt942` runs on macOS, Linux, and Windows and
77
+ requires **Python 3.10+** and **pip**. It pulls in
78
+ [`bankstatementparser`][core] (>= 0.0.9) automatically.
79
+
80
+ ```bash
81
+ pip install bankstatementparser-loader-mt942
82
+ ```
83
+
84
+ ## Quick Start
85
+
86
+ ```python
87
+ from bankstatementparser_loader_mt942 import load_mt942
88
+
89
+ mt942 = """:20:MT942REF001
90
+ :25:COBADEFFXXX/DE89370400440532013000
91
+ :28C:42/1
92
+ :34F:EURD0,00
93
+ :34F:EURC0,00
94
+ :13D:2506241200+0100
95
+ :61:2506240624C500,00NTRFINV-123//BANKREF1
96
+ :86:Incoming payment for invoice 123
97
+ :61:2506240624D200,50NTRFRENT//BANKREF2
98
+ :86:Monthly rent debit
99
+ :90D:1EUR200,50
100
+ :90C:1EUR500,00
101
+ -
102
+ """
103
+
104
+ transactions = load_mt942(mt942)
105
+
106
+ for txn in transactions:
107
+ print(txn.value_date, txn.currency, txn.amount, txn.description)
108
+ # 2025-06-24 EUR 500.00 Incoming payment for invoice 123
109
+ # 2025-06-24 EUR -200.50 Monthly rent debit
110
+ ```
111
+
112
+ Those are `bankstatementparser.transaction_models.Transaction` objects —
113
+ debit lines carry a **negative** amount, credit lines a **positive** one,
114
+ and amounts are exact `Decimal` values (SWIFT's comma decimal separator
115
+ `500,00` is converted to `Decimal("500.00")`).
116
+
117
+ To read from a file instead of a string:
118
+
119
+ ```python
120
+ from bankstatementparser_loader_mt942 import load_mt942_file
121
+
122
+ transactions = load_mt942_file("statement.mt942")
123
+ ```
124
+
125
+ ## Supported Fields
126
+
127
+ | Tag | Meaning | Cardinality |
128
+ | :--- | :--- | :--- |
129
+ | `:20:` | Transaction reference number | mandatory |
130
+ | `:25:` | Account identification (the account id) | mandatory |
131
+ | `:28C:` | Statement / sequence number | optional |
132
+ | `:34F:` | Floor limit indicator `<CCY>[D\|C]<amount>` (provides the currency) | one or two |
133
+ | `:13D:` | Date/time stamp `YYMMDDHHMM±HHMM` | optional |
134
+ | `:61:` | Statement line (one per booked entry) | repeatable |
135
+ | `:86:` | Information to account owner (attaches to the preceding `:61:`) | optional, per line |
136
+ | `:90D:` | Debit summary `<count><CCY><amount>` | optional |
137
+ | `:90C:` | Credit summary `<count><CCY><amount>` | optional |
138
+
139
+ Unrecognised tags (and the trailing `-` end-of-message marker) are
140
+ **silently ignored**, so future SWIFT additions do not break parsing —
141
+ this follows Postel's law: be liberal in what you accept. A malformed
142
+ `:61:` statement line is **skipped** rather than fatal, so one bad row
143
+ never aborts the whole parse.
144
+
145
+ ## Field Mapping
146
+
147
+ Each `:61:` statement line becomes one `Transaction`:
148
+
149
+ | `Transaction` field | Source |
150
+ | :--- | :--- |
151
+ | `account_id` | `:25:` |
152
+ | `currency` | `:34F:` |
153
+ | `amount` | `:61:` amount, negated for debit (`D`) lines, as `Decimal` |
154
+ | `value_date` | `:61:` value date (`YYMMDD`) |
155
+ | `booking_date` | `:61:` optional entry date (`MMDD`, inherits the value-date year) |
156
+ | `transaction_id` | the bank reference in the `:61:` tail (left of `//`) |
157
+ | `reference` | the customer reference in the `:61:` tail (right of `//`) |
158
+ | `description` | the following `:86:` free-form text |
159
+ | `source` | always `"mt942"` |
160
+ | `source_index` | the zero-based line index within the message |
161
+
162
+ The two-digit `YY` year uses the standard SWIFT sliding window: `00`-`79`
163
+ map to `20YY`, `80`-`99` to `19YY`.
164
+
165
+ ## Summaries
166
+
167
+ When you want the message metadata and the `:90D:`/`:90C:` roll-ups
168
+ rather than the individual transactions, call `summarize_mt942`:
169
+
170
+ ```python
171
+ from bankstatementparser_loader_mt942 import summarize_mt942
172
+
173
+ summary = summarize_mt942(mt942)
174
+ print(summary.reference) # MT942REF001
175
+ print(summary.currency) # EUR
176
+ print(summary.debit_count) # 1
177
+ print(summary.debit_sum) # Decimal("200.50")
178
+ print(summary.credit_sum) # Decimal("500.00")
179
+ print(summary.transaction_count) # 2
180
+ ```
181
+
182
+ `Mt942Summary` is a frozen dataclass with `reference`, `account_id`,
183
+ `currency`, `statement_datetime`, `debit_count`, `debit_sum`,
184
+ `credit_count`, `credit_sum`, and `transaction_count`. Optional fields
185
+ (no `:90x:` / `:13D:` present) default to `None`.
186
+
187
+ ## Errors
188
+
189
+ A payload missing the mandatory `:20:` reference or `:25:` account
190
+ identification raises `ValueError` with a message naming the missing tag:
191
+
192
+ ```python
193
+ load_mt942(":25:ACC\n:61:250624C10,00NTRFX\n")
194
+ # ValueError: MT942 payload missing required :20: reference
195
+ ```
196
+
197
+ ## Examples
198
+
199
+ Two runnable examples live in [`examples/`](examples/):
200
+
201
+ - [`01_load_mt942.py`](examples/01_load_mt942.py) — parse a small MT942
202
+ string into transactions.
203
+ - [`02_summarize_mt942.py`](examples/02_summarize_mt942.py) — read a file
204
+ and print the `Mt942Summary` roll-ups.
205
+
206
+ Both are exercised in CI on every commit.
207
+
208
+ ## When not to use this loader
209
+
210
+ - **You have a PDF or CSV statement.** Use the core
211
+ [`bankstatementparser`][core] parsers directly — this loader is only
212
+ for the SWIFT MT942 wire format.
213
+ - **You have MT940 (final statements with balances).** MT940 is a
214
+ different message; this loader handles the interim MT942 report. The
215
+ `:61:` / `:86:` grammar is shared, but MT942 has no `:60F:` / `:62F:`
216
+ balances.
217
+ - **You need bank-specific `:86:` sub-field parsing** (e.g. Deutsche
218
+ Bank's `?20` / `?30` / `?32` GVC codes). The raw `:86:` value is
219
+ preserved verbatim as the transaction `description`; downstream tooling
220
+ can parse it if needed.
221
+ - **Your MT942 is PGP / GPG encrypted.** Decrypt upstream and pass the
222
+ plaintext to the loader.
223
+
224
+ ## Development
225
+
226
+ ```bash
227
+ git clone https://github.com/sebastienrousseau/bankstatementparser-loader-mt942
228
+ cd bankstatementparser-loader-mt942
229
+ python -m venv .venv && source .venv/bin/activate
230
+ pip install -e .
231
+ pip install pytest pytest-cov ruff mypy interrogate
232
+ pytest --cov=bankstatementparser_loader_mt942 --cov-branch --cov-fail-under=100
233
+ ruff check bankstatementparser_loader_mt942 tests examples
234
+ mypy bankstatementparser_loader_mt942
235
+ interrogate -c pyproject.toml bankstatementparser_loader_mt942
236
+ ```
237
+
238
+ ## Security
239
+
240
+ `bankstatementparser-loader-mt942` parses a flat text format with no XML
241
+ envelope, so the XXE / billion-laughs surface does not apply. Field
242
+ regexes are anchored and bounded, so catastrophic backtracking is not a
243
+ concern. Reporting practice and supported versions are documented in
244
+ [`SECURITY.md`](SECURITY.md). Vulnerabilities go via GitHub Private
245
+ Vulnerability Reporting, not public issues.
246
+
247
+ ## Documentation
248
+
249
+ - [`README.md`](README.md) — this file
250
+ - [`ARCHITECTURE.md`](ARCHITECTURE.md) — codebase map
251
+ - [`CHANGELOG.md`](CHANGELOG.md) — release notes
252
+ - [`ROADMAP.md`](ROADMAP.md) — what's next
253
+ - [`SECURITY.md`](SECURITY.md) — disclosure + supported versions
254
+ - [`examples/`](examples/) — runnable scripts, exercised in CI
255
+
256
+ ## License
257
+
258
+ Licensed under the [Apache License, Version 2.0][license-url]. Any
259
+ contribution submitted for inclusion shall be licensed as above, without
260
+ additional terms.
261
+
262
+ ## Contributing
263
+
264
+ Contributions are welcome. Open an issue or PR on
265
+ [the repository](https://github.com/sebastienrousseau/bankstatementparser-loader-mt942).
266
+
267
+ ## Acknowledgements
268
+
269
+ Built on the [`bankstatementparser`][core] library. The MT942 grammar
270
+ follows the SWIFT User Handbook MT942 _Interim Transaction Report_
271
+ specification and the common-denominator subset shipped by major EU and
272
+ UK commercial banks.
273
+
274
+ [core]: https://github.com/sebastienrousseau/bankstatementparser
275
+ [pypi-url]: https://pypi.org/project/bankstatementparser-loader-mt942/
276
+ [license-url]: https://opensource.org/license/apache-2-0/
277
+ [tests-url]: https://github.com/sebastienrousseau/bankstatementparser-loader-mt942/actions/workflows/ci.yml
278
+ [pypi-badge]: https://img.shields.io/pypi/v/bankstatementparser-loader-mt942.svg?style=for-the-badge
279
+ [python-versions-badge]: https://img.shields.io/pypi/pyversions/bankstatementparser-loader-mt942.svg?style=for-the-badge
280
+ [license-badge]: https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=for-the-badge
281
+ [tests-badge]: https://img.shields.io/github/actions/workflow/status/sebastienrousseau/bankstatementparser-loader-mt942/ci.yml?branch=main&label=Tests&style=for-the-badge
282
+ [quality-badge]: https://img.shields.io/badge/Coverage-100%25-brightgreen?style=for-the-badge
283
+
@@ -0,0 +1,6 @@
1
+ bankstatementparser_loader_mt942/__init__.py,sha256=jV9tM3Xbmp6_sLRQ4j2s8Mxh87DbAPORKAE8OAc4Znc,1212
2
+ bankstatementparser_loader_mt942/loader.py,sha256=pFYSH2dgrDdhzpjdg5TMhJ-58UCnXXMlXdSQbY1f_GQ,19430
3
+ bankstatementparser_loader_mt942-0.0.10.dist-info/METADATA,sha256=cgVR6vN27lTAmAh_bRflUXVdwRtdv5rHbeiK9mk6OXY,10976
4
+ bankstatementparser_loader_mt942-0.0.10.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
5
+ bankstatementparser_loader_mt942-0.0.10.dist-info/licenses/LICENSE,sha256=BhsQ65IVGLlScIgEHcMm2leCnoRbQqwPcLd7N2c8Gt0,10244
6
+ bankstatementparser_loader_mt942-0.0.10.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,189 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work.
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean any work of authorship, including
48
+ the original version of the Work and any modifications or additions
49
+ to that Work or Derivative Works thereof, that is intentionally
50
+ submitted to the Licensor for inclusion in the Work by the copyright owner
51
+ or by an individual or Legal Entity authorized to submit on behalf of
52
+ the copyright owner. For the purposes of this definition, "submitted"
53
+ means any form of electronic, verbal, or written communication sent
54
+ to the Licensor or its representatives, including but not limited to
55
+ communication on electronic mailing lists, source code control systems,
56
+ and issue tracking systems that are managed by, or on behalf of, the
57
+ Licensor for the purpose of discussing and improving the Work, but
58
+ excluding communication that is conspicuously marked or otherwise
59
+ designated in writing by the copyright owner as "Not a Contribution."
60
+
61
+ "Contributor" shall mean Licensor and any individual or Legal Entity
62
+ on behalf of whom a Contribution has been received by the Licensor and
63
+ subsequently incorporated within the Work.
64
+
65
+ 2. Grant of Copyright License. Subject to the terms and conditions of
66
+ this License, each Contributor hereby grants to You a perpetual,
67
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
68
+ copyright license to reproduce, prepare Derivative Works of,
69
+ publicly display, publicly perform, sublicense, and distribute the
70
+ Work and such Derivative Works in Source or Object form.
71
+
72
+ 3. Grant of Patent License. Subject to the terms and conditions of
73
+ this License, each Contributor hereby grants to You a perpetual,
74
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
75
+ (except as stated in this section) patent license to make, have made,
76
+ use, offer to sell, sell, import, and otherwise transfer the Work,
77
+ where such license applies only to those patent claims licensable
78
+ by such Contributor that are necessarily infringed by their
79
+ Contribution(s) alone or by combination of their Contribution(s)
80
+ with the Work to which such Contribution(s) was submitted. If You
81
+ institute patent litigation against any entity (including a
82
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
83
+ or a Contribution incorporated within the Work constitutes direct
84
+ or contributory patent infringement, then any patent licenses
85
+ granted to You under this License for that Work shall terminate
86
+ as of the date such litigation is filed.
87
+
88
+ 4. Redistribution. You may reproduce and distribute copies of the
89
+ Work or Derivative Works thereof in any medium, with or without
90
+ modifications, and in Source or Object form, provided that You
91
+ meet the following conditions:
92
+
93
+ (a) You must give any other recipients of the Work or
94
+ Derivative Works a copy of this License; and
95
+
96
+ (b) You must cause any modified files to carry prominent notices
97
+ stating that You changed the files; and
98
+
99
+ (c) You must retain, in the Source form of any Derivative Works
100
+ that You distribute, all copyright, patent, trademark, and
101
+ attribution notices from the Source form of the Work,
102
+ excluding those notices that do not pertain to any part of
103
+ the Derivative Works; and
104
+
105
+ (d) If the Work includes a "NOTICE" text file as part of its
106
+ distribution, then any Derivative Works that You distribute must
107
+ include a readable copy of the attribution notices contained
108
+ within such NOTICE file, excluding any notices that do not
109
+ pertain to any part of the Derivative Works, in at least one
110
+ of the following places: within a NOTICE text file distributed
111
+ as part of the Derivative Works; within the Source form or
112
+ documentation, if provided along with the Derivative Works; or,
113
+ within a display generated by the Derivative Works, if and
114
+ wherever such third-party notices normally appear. The contents
115
+ of the NOTICE file are for informational purposes only and
116
+ do not modify the License. You may add Your own attribution
117
+ notices within Derivative Works that You distribute, alongside
118
+ or as an addendum to the NOTICE text from the Work, provided
119
+ that such additional attribution notices cannot be construed
120
+ as modifying the License.
121
+
122
+ You may add Your own copyright statement to Your modifications and
123
+ may provide additional or different license terms and conditions
124
+ for use, reproduction, or distribution of Your modifications, or
125
+ for any such Derivative Works as a whole, provided Your use,
126
+ reproduction, and distribution of the Work otherwise complies with
127
+ the conditions stated in this License.
128
+
129
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
130
+ any Contribution intentionally submitted for inclusion in the Work
131
+ by You to the Licensor shall be under the terms and conditions of
132
+ this License, without any additional terms or conditions.
133
+ Notwithstanding the above, nothing herein shall supersede or modify
134
+ the terms of any separate license agreement you may have executed
135
+ with Licensor regarding such Contributions.
136
+
137
+ 6. Trademarks. This License does not grant permission to use the trade
138
+ names, trademarks, service marks, or product names of the Licensor,
139
+ except as required for reasonable and customary use in describing the
140
+ origin of the Work and reproducing the content of the NOTICE file.
141
+
142
+ 7. Disclaimer of Warranty. Unless required by applicable law or
143
+ agreed to in writing, Licensor provides the Work (and each
144
+ Contributor provides its Contributions) on an "AS IS" BASIS,
145
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
146
+ implied, including, without limitation, any warranties or conditions
147
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
148
+ PARTICULAR PURPOSE. You are solely responsible for determining the
149
+ appropriateness of using or redistributing the Work and assume any
150
+ risks associated with Your exercise of permissions under this License.
151
+
152
+ 8. Limitation of Liability. In no event and under no legal theory,
153
+ whether in tort (including negligence), contract, or otherwise,
154
+ unless required by applicable law (such as deliberate and grossly
155
+ negligent acts) or agreed to in writing, shall any Contributor be
156
+ liable to You for damages, including any direct, indirect, special,
157
+ incidental, or consequential damages of any character arising as a
158
+ result of this License or out of the use or inability to use the
159
+ Work (including but not limited to damages for loss of goodwill,
160
+ work stoppage, computer failure or malfunction, or any and all
161
+ other commercial damages or losses), even if such Contributor
162
+ has been advised of the possibility of such damages.
163
+
164
+ 9. Accepting Warranty or Additional Liability. While redistributing
165
+ the Work or Derivative Works thereof, You may choose to offer,
166
+ and charge a fee for, acceptance of support, warranty, indemnity,
167
+ or other liability obligations and/or rights consistent with this
168
+ License. However, in accepting such obligations, You may act only
169
+ on Your own behalf and on Your sole responsibility, not on behalf
170
+ of any other Contributor, and only if You agree to indemnify,
171
+ defend, and hold each Contributor harmless for any liability
172
+ incurred by, or claims asserted against, such Contributor by reason
173
+ of your accepting any such warranty or additional liability.
174
+
175
+ END OF TERMS AND CONDITIONS
176
+
177
+ Copyright 2023-2026 Sebastien Rousseau
178
+
179
+ Licensed under the Apache License, Version 2.0 (the "License");
180
+ you may not use this file except in compliance with the License.
181
+ You may obtain a copy of the License at
182
+
183
+ http://www.apache.org/licenses/LICENSE-2.0
184
+
185
+ Unless required by applicable law or agreed to in writing, software
186
+ distributed under the License is distributed on an "AS IS" BASIS,
187
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
188
+ See the License for the specific language governing permissions and
189
+ limitations under the License.