<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kolos Hetyéssy</title>
    <description>The latest articles on DEV Community by Kolos Hetyéssy (@ssykolos).</description>
    <link>https://dev.to/ssykolos</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4199267%2Fc532e7c6-9500-4097-af8b-04b218689379.jpg</url>
      <title>DEV Community: Kolos Hetyéssy</title>
      <link>https://dev.to/ssykolos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ssykolos"/>
    <language>en</language>
    <item>
      <title>Why MT940 and SEPA files break, and how to check them without guessing</title>
      <dc:creator>Kolos Hetyéssy</dc:creator>
      <pubDate>Wed, 21 Oct 2026 23:06:39 +0000</pubDate>
      <link>https://dev.to/ssykolos/why-mt940-and-sepa-files-break-and-how-to-check-them-without-guessing-23ch</link>
      <guid>https://dev.to/ssykolos/why-mt940-and-sepa-files-break-and-how-to-check-them-without-guessing-23ch</guid>
      <description>&lt;p&gt;I'm a mechatronics engineering student in Hungary. Over the last months I wrote two small parsers/validators: one that reads bank statement files (MT940, camt.053, BAI2, CODA) and checks that the numbers add up, and one that checks and converts SEPA payment files (pain.001 / pain.008). Both are deterministic: no AI, no fuzzy matching, the same file always gives the same answer.&lt;/p&gt;

&lt;p&gt;This post is about the boring part that took most of the time: the ways these "standard" files differ from the standard. If you have ever written &lt;code&gt;split(":61:")&lt;/code&gt; and felt good about it, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: MT940
&lt;/h2&gt;

&lt;p&gt;MT940 is a SWIFT text format from the 1980s. A statement looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;:20:ROLL-2025-12
:25:DE89370400440532013000
:28C:1/1
:60F:C251230EUR1000,00
:61:2512310102C500,00NTRFNONREF
:86:Year end payment
:61:2501150102D200,00NRTINONREF
:86:SEPA return of January 2025 collection
:62F:C260102EUR1300,00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:60F:&lt;/code&gt; is the opening balance, &lt;code&gt;:61:&lt;/code&gt; is one transaction, &lt;code&gt;:86:&lt;/code&gt; is free text about that transaction, &lt;code&gt;:62F:&lt;/code&gt; is the closing balance. Simple. Here is where it goes wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The :61: entry date has no year
&lt;/h3&gt;

&lt;p&gt;The first six digits of &lt;code&gt;:61:&lt;/code&gt; are the value date as &lt;code&gt;YYMMDD&lt;/code&gt;. The next four are the booking (entry) date, but only as &lt;code&gt;MMDD&lt;/code&gt;. No year.&lt;/p&gt;

&lt;p&gt;The obvious fix is "take the year from the value date". Look at the first transaction above: value date &lt;code&gt;251231&lt;/code&gt;, booking date &lt;code&gt;0102&lt;/code&gt;. Taking the year from the value date gives 2 January 2025, which is a year off. This exact bug is reported in the popular Python &lt;code&gt;mt940&lt;/code&gt; library (issue #17).&lt;/p&gt;

&lt;p&gt;The second transaction is worse. It is a SEPA direct debit return: the value date is 15 January 2025, but the bank booked it on 2 January 2026. The value date is more than a year before the booking. Any rule based on the value date picks the wrong year.&lt;/p&gt;

&lt;p&gt;What worked for me: anchor the booking date to the statement window, not to the value date. The statement says it covers 30 Dec 2025 to 2 Jan 2026 (&lt;code&gt;:60F:&lt;/code&gt; and &lt;code&gt;:62F:&lt;/code&gt; dates), so for &lt;code&gt;MMDD = 0102&lt;/code&gt; I try the previous, current and next year around that window and take the candidate that falls inside it (with a 7-day margin):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_resolve_entry_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mmdd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_end&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;mmdd&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;mmdd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value_date&lt;/span&gt;            &lt;span class="c1"&gt;# some banks leave it blank
&lt;/span&gt;    &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mmdd&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mmdd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
    &lt;span class="n"&gt;anchor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window_end&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;value_date&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_safe_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window_start&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;window_end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window_end&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Shortened; the real version also handles statements without balance dates.) Both transactions above now get &lt;code&gt;2026-01-02&lt;/code&gt;, and there is a test for exactly this file.&lt;/p&gt;

&lt;p&gt;Two smaller date surprises from real files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some banks (ASN, Citi) leave the four entry-date characters as spaces. The regex has to allow &lt;code&gt;\d{4}| {4}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;German banks book interest on "30 February". &lt;code&gt;date(2026, 2, 30)&lt;/code&gt; raises, so days 29 to 31 are clamped to the last day of the month.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. :86: is not one format, it is at least four
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;:86:&lt;/code&gt; is "information to account owner". SWIFT barely defines it, so every banking community filled it differently. The ones I had to support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GVC subfields&lt;/strong&gt; (German banks, Sparkassen, Volksbanken, PostFinance, Raiffeisen):
&lt;code&gt;166?00GUTSCHR. UEBERWEISUNG?20EREF+...?30COBADEFFXXX?31DE89...?32Müller Maschinenbau GmbH&lt;/code&gt;
A three-digit transaction code, then &lt;code&gt;?NN&lt;/code&gt; subfields: 20 to 29 are purpose lines, 30 is the bank code or BIC, 31 the account, 32/33 the name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slash tags&lt;/strong&gt; (ING, Rabobank, ABN AMRO, Triodos, KBC, Wise):
&lt;code&gt;/EREF/.../CNTP/NL91ABNA0417164300/ABNANL2A/Name/City//REMI/USTD//text/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEPA keywords in plain text&lt;/strong&gt;: &lt;code&gt;EREF+... MREF+... SVWZ+...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free text&lt;/strong&gt;: everything else, like &lt;code&gt;Year end payment&lt;/code&gt; above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the separators are not fixed either. Polish banks use the ZBP standard with &lt;code&gt;~&lt;/code&gt; instead of &lt;code&gt;?&lt;/code&gt;: &lt;code&gt;034~00VE02~20Faktura 123 za usługi~21księgowe~32Zażółć Gęślą Jaźń&lt;/code&gt;. I have also seen &lt;code&gt;@&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt; and &lt;code&gt;^&lt;/code&gt; in exports. My detection regex for the GVC style is now &lt;code&gt;^(\d{3})([?@&amp;gt;|~^])\d{2}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The nastiest detail: SVWZ (the purpose text) is often split across subfields in the middle of a word, e.g. &lt;code&gt;?23Vodafone Rechnung Jan&lt;/code&gt; + &lt;code&gt;?24uar 2026&lt;/code&gt;. If you join subfields with a space, you get "Jan uar". The parser has to know which subfields are continuation lines.&lt;/p&gt;

&lt;p&gt;Another one: in German files &lt;code&gt;?30&lt;/code&gt; is often a German bank code (Bankleitzahl, 8 digits), not a BIC. If you put it in a "BIC" column, the downstream payment run fails. There is a test that checks &lt;code&gt;37040044&lt;/code&gt; is not reported as a BIC.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Encodings
&lt;/h3&gt;

&lt;p&gt;MT940 is supposed to be in the SWIFT character set, which has no accented letters. Real files have them anyway, and they don't tell you the encoding.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Valid UTF-8? Use it.&lt;/li&gt;
&lt;li&gt;Otherwise, Western European banks usually export Windows-1252.&lt;/li&gt;
&lt;li&gt;Hungarian, Polish and Czech banks use Windows-1250, ISO-8859-2 or the old DOS code page 852.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Guessing wrong turns the Hungarian "tűzőgép" (stapler) into "tûzõgép" (Windows-1250 read as 1252); the Polish "Zażółć" becomes "Za¿ó³æ". Being Hungarian, this one was personal. My approach: decode as Windows-1252 first. If the account (&lt;code&gt;:25:&lt;/code&gt;) or a balance currency is Central/Eastern European (HU, PL, CZ, ... or HUF, PLN, CZK, ...), try all three CEE encodings and score each result: +1 for every real accented letter of those languages, -3 for every typical mojibake character (&lt;code&gt;±&lt;/code&gt;, &lt;code&gt;¶&lt;/code&gt;, &lt;code&gt;ˇ&lt;/code&gt;, box-drawing characters, C1 control codes). Highest score wins.&lt;/p&gt;

&lt;p&gt;It is a heuristic, but a deterministic one, and it is tested against the same Hungarian sentence encoded in Windows-1250 and ISO-8859-2.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The actual check: do the numbers add up?
&lt;/h3&gt;

&lt;p&gt;After parsing, the useful question is simple: does &lt;code&gt;opening + credits - debits&lt;/code&gt; equal the bank's closing balance? For the file above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"opening_balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1000.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total_credits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"500.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total_debits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"200.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"calculated_closing_balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1300.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"closing_balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1300.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"discrepancy_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"audit_verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VERIFIED"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Amounts are &lt;code&gt;Decimal&lt;/code&gt;, never float. There are three verdicts: &lt;code&gt;VERIFIED&lt;/code&gt; (matches), &lt;code&gt;DISCREPANCY&lt;/code&gt; (doesn't match, or the next statement's opening balance doesn't continue the previous closing balance), and &lt;code&gt;UNVERIFIABLE&lt;/code&gt; (the file has no closing balance to compare with). A parser that silently drops one weird &lt;code&gt;:61:&lt;/code&gt; line still "works", which is why the balance check matters more to me than any single parsing rule: a dropped transaction shows up as a discrepancy instead of a wrong ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: SEPA payment files (pain.001)
&lt;/h2&gt;

&lt;p&gt;The other direction: files you send to the bank. Here the problems are less about parsing and more about rules that the XML schema does not check.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. pain.001.001.03 to .09
&lt;/h3&gt;

&lt;p&gt;Many ERPs still produce the 2009 version, &lt;code&gt;pain.001.001.03&lt;/code&gt;. Banks are moving to the 2019 version, &lt;code&gt;pain.001.001.09&lt;/code&gt;; the German banks' specification limited the old version to November 2025.&lt;/p&gt;

&lt;p&gt;When I compared the two schemas' element paths, the differences that actually occur in SEPA credit transfers were small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;BIC&lt;/code&gt; becomes &lt;code&gt;BICFI&lt;/code&gt; for banks (&lt;code&gt;FinInstnId&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BICOrBEI&lt;/code&gt; becomes &lt;code&gt;AnyBIC&lt;/code&gt; for organisation IDs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ReqdExctnDt&lt;/code&gt; (execution date) gets a child: &lt;code&gt;&amp;lt;ReqdExctnDt&amp;gt;&amp;lt;Dt&amp;gt;2026-10-23&amp;lt;/Dt&amp;gt;&amp;lt;/ReqdExctnDt&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The namespace changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So conversion is mostly renaming. The part I refused to automate is postal addresses. The 2019 version supports structured addresses (&lt;code&gt;StrtNm&lt;/code&gt;, &lt;code&gt;PstCd&lt;/code&gt;, &lt;code&gt;TwnNm&lt;/code&gt;, &lt;code&gt;Ctry&lt;/code&gt;), and the EPC is phasing out unstructured ones (address lines only); the end date has been postponed and not set yet. It is tempting to split &lt;code&gt;&amp;lt;AdrLine&amp;gt;10115 Berlin&amp;lt;/AdrLine&amp;gt;&lt;/code&gt; into postcode and town with a regex. I don't. The converter keeps the lines as they are and reports an &lt;code&gt;UNSTRUCTURED_ADDRESS&lt;/code&gt; warning, because a guessed address on a payment is worse than an honest warning. In the new version, if you do use structured fields, town and country are both required; that is an error, not a warning.&lt;/p&gt;

&lt;p&gt;The converted file is validated against the 2019 schema and the SEPA rules before it is returned. If it would not be valid, you get the problems instead of a file.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Character set
&lt;/h3&gt;

&lt;p&gt;The EPC implementation guidelines define the Latin character set every bank must accept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a-z A-Z 0-9 / - ? : ( ) . , ' + space
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all. No &lt;code&gt;&amp;amp;&lt;/code&gt;, no &lt;code&gt;ü&lt;/code&gt;, no &lt;code&gt;ő&lt;/code&gt;. German banks additionally accept &lt;code&gt;Ä Ö Ü ä ö ü ß &amp;amp; *&lt;/code&gt;. So the same name is valid or invalid depending on where you send it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;Cdtr&amp;gt;&amp;lt;Nm&amp;gt;Müller &amp;amp;amp; Söhne&amp;lt;/Nm&amp;gt;&amp;lt;/Cdtr&amp;gt;

profile=epc -&amp;gt; CHARACTER_SET: &amp;lt;Nm&amp;gt; contains characters outside the SEPA Latin character set: '&amp;amp;' 'ö' 'ü'.
profile=de  -&amp;gt; no errors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's why my validator has a profile parameter instead of one global rule. Banks differ in whether they reject the file, replace the characters, or (worst) accept it and mangle the name. Also: IDs like &lt;code&gt;EndToEndId&lt;/code&gt; must not start or end with &lt;code&gt;/&lt;/code&gt; or contain &lt;code&gt;//&lt;/code&gt;, and names are limited to 70 characters.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Control sums
&lt;/h3&gt;

&lt;p&gt;The group header and each payment block carry &lt;code&gt;NbOfTxs&lt;/code&gt; (number of transactions) and &lt;code&gt;CtrlSum&lt;/code&gt; (sum of amounts). The XSD only checks that they are well-formed numbers, not that they are correct. When someone edits a file by hand, or an export adds a payment after the header was written, you get this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONTROL_SUM at GrpHdr/CtrlSum:
The message control sum is 999.00; the amounts add up to 1284.06.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Banks reject such files, sometimes hours later in a pain.002 status report. Checking it before upload is a few lines of &lt;code&gt;Decimal&lt;/code&gt; arithmetic. Same for &lt;code&gt;NbOfTxs&lt;/code&gt;, amounts with more than two decimals, non-EUR currencies, and &lt;code&gt;ChrgBr&lt;/code&gt; other than &lt;code&gt;SLEV&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;

&lt;p&gt;Both checks run as HTTP APIs. Uploading a statement file looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://bank-statement-reconciler1.p.rapidapi.com/v1/reconcile"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-RapidAPI-Key: &lt;/span&gt;&lt;span class="nv"&gt;$RAPIDAPI_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-RapidAPI-Host: bank-statement-reconciler1.p.rapidapi.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@statement.sta"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a payment file goes in as the raw XML body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://sepa-payment-file.p.rapidapi.com/v1/validate?profile=de"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-RapidAPI-Key: &lt;/span&gt;&lt;span class="nv"&gt;$RAPIDAPI_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-RapidAPI-Host: sepa-payment-file.p.rapidapi.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/xml"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @payments.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you just want to see whether your own file parses, there is a free browser demo, no sign-up: &lt;a href="https://ssykolos.github.io/tools/bank-statement-balance-check/?ref=devto" rel="noopener noreferrer"&gt;bank statement balance check&lt;/a&gt;. The file is processed in memory and discarded, and names and IBANs are masked in the result by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write tests from real files, not from the spec. Almost every rule above came from a file that broke.&lt;/li&gt;
&lt;li&gt;Prefer a loud warning to a clever guess (addresses, unknown encodings, missing balances).&lt;/li&gt;
&lt;li&gt;Make the output checkable. A balance check catches parser bugs you don't know you have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have an MT940 or pain.001 file that breaks in a way I didn't describe, I would really like to hear about it in the comments. Weird bank exports are the only way these parsers get better.&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>python</category>
      <category>banking</category>
      <category>xml</category>
    </item>
  </channel>
</rss>
