From 7ca192af4cdd16ff15c7c98e5f35d90fec054a73 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 23:56:35 +0100 Subject: [PATCH] be: read tatra banka's b-mail from public samples, its id from the signed subject --- .docs/banks/README.md | 2 +- .docs/banks/sk/tatra/README.md | 42 ++++++ README.md | 2 +- .../fixtures/tatra-sk/incoming-synthetic.eml | 25 ++++ .../insuit/bank/notification/TatraBanka.java | 135 ++++++++++++++++++ .../bank/notification/TatraBankaTest.java | 112 +++++++++++++++ .../bank/notification/BankNotifications.java | 3 +- 7 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 .docs/banks/sk/tatra/README.md create mode 100644 modules/bank-notifications/src/banks/sk/tatra/fixtures/tatra-sk/incoming-synthetic.eml create mode 100644 modules/bank-notifications/src/banks/sk/tatra/java/cz/insuit/bank/notification/TatraBanka.java create mode 100644 modules/bank-notifications/src/banks/sk/tatra/test/cz/insuit/bank/notification/TatraBankaTest.java diff --git a/.docs/banks/README.md b/.docs/banks/README.md index e769c6d..656f00b 100644 --- a/.docs/banks/README.md +++ b/.docs/banks/README.md @@ -36,7 +36,7 @@ A bank is in one of three states: | Fio banka | not yet | | | [#10](https://github.com/landsman/bank-java/issues/10) | | ČSOB | samples only | incoming SEPA credits; IBANs, name, symbols from the payer's reference, message | outgoing payments, transaction id; the 2019 sample was not DKIM-signed and `csob.sk` has no DNSSEC | [csob](sk/csob/README.md) | | Slovenská sporiteľňa | not yet | | | [#23](https://github.com/landsman/bank-java/issues/23) | -| Tatra banka | not yet | | key known, still published | [#24](https://github.com/landsman/bank-java/issues/24) | +| Tatra banka | samples only | credits and debits; transaction id and direction from the signed subject, IBAN, name, symbols from the payer's reference, balance | counter-account, type; its 2019 key is published but not pinned | [tatra](sk/tatra/README.md) | What a missing transaction id means for deduplication, and a masked account for knowing whose payment it is: [using the library](../usage.md#two-checks-stay-with-you). A bank that is not yet read has an issue collecting what is diff --git a/.docs/banks/sk/tatra/README.md b/.docs/banks/sk/tatra/README.md new file mode 100644 index 0000000..bd379b6 --- /dev/null +++ b/.docs/banks/sk/tatra/README.md @@ -0,0 +1,42 @@ +# How Tatra banka's e-mail behaves + +**Not seen as a real e-mail yet (2026-11-10).** The parser follows +[remp2020's raw e-mail](https://github.com/remp2020/crm-payments-module/blob/master/docs/tatrabanka.eml) (2019) and +[tomaj/bank-mails-parser](https://github.com/tomaj/bank-mails-parser/tree/master/tests/fixtures/tatrabanka)'s fixtures +(2015), both MIT. That e-mail was signed with selector `mta`, and `mta._domainkey.tatrabanka.sk` is still published: +RSA 2048, DNSSEC-secure, fingerprint `sOUiSq9+xyeXpczY9SzI+HLjzU4jZyfQUXGvxjtXmVc=` (2026-11-10, through 1.1.1.1 and +8.8.8.8). It is not pinned until a real e-mail confirms the template and that Tatra still signs with it, so `withDns()` +refuses every Tatra banka e-mail as `Unverified`. See [#24](https://github.com/landsman/bank-java/issues/24). + +## What it carries + +A B-mail from `b-mail@tatrabanka.sk`, **ISO-8859-2** without diacritics, subject `Kredit na ucte (ID=111219/320305-1)`, +`c=simple/simple` so the body is signed byte for byte: + +``` +11.12.2019 13:13 bol zostatok Vasho uctu SK0211000000002946123456 zvyseny o 4,00 EUR. +uctovny zostatok: 88 888,62 EUR +aktualny zostatok: 88 888,62 EUR +disponibilny zostatok: 88 888,62 EUR + +Popis transakcie: CCINT 1100/000000-2611123000 +Referencia platitela: /VS12319670000/SS123/KS308 +Ucet protistrany: Novak Jozef, Ing. +``` + +- **The subject carries the transaction id** (`ID=111219/320305-1`) **and the direction** (`Kredit`; `Debet` is + assumed for money out), and it is signed. The sentence states the direction again (`zvyseny` / `znizeny`); the two + have to agree. Another subject is refused. +- **The account is an IBAN**, read with `BankAccount.parseIban`. The sample's is anonymised and fails its check digits. +- **The symbols come from the payer's reference** (`/VS…/SS…/KS…`), text the payer wrote. One not in that form, ten + digits at most per symbol, leaves them out instead of refusing the payment: the sample's variable symbol has eleven. + The reference line is required, as every sample prints it. +- **`Ucet protistrany` holds the other side's name**, not an account; no counter-account is given. After it and after + `Informacia pre prijemcu`, the payer's text, no other labelled line may follow. +- **The balance** is `disponibilny zostatok`, at the sentence's time, in Europe/Bratislava. +- `type()` is `GENERAL`: the description (`CCINT …`) is not mapped without more samples. + +## Setting it up + +[Fakturoid's guide](https://www.fakturoid.cz/podpora/parovani/tatra): NASTAVENIA → B-mail → VYTVORIŤ NOVÝ; Email, tick +Vklad and Výber with an amount. diff --git a/README.md b/README.md index ed4a272..8f4aaf4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ For Java 21+ and Kotlin, one jar, no framework required. ## Which banks -Air Bank and Fio banka CZ from real e-mails; MONETA, Creditas, Raiffeisenbank, UniCredit and ČSOB, and ČSOB Slovakia, from public samples, until a real +Air Bank and Fio banka CZ from real e-mails; MONETA, Creditas, Raiffeisenbank, UniCredit and ČSOB, and ČSOB Slovakia and Tatra banka, from public samples, until a real e-mail confirms them. Each bank, and what it reads: [banks](.docs/banks/README.md). ## Docs diff --git a/modules/bank-notifications/src/banks/sk/tatra/fixtures/tatra-sk/incoming-synthetic.eml b/modules/bank-notifications/src/banks/sk/tatra/fixtures/tatra-sk/incoming-synthetic.eml new file mode 100644 index 0000000..b68ee47 --- /dev/null +++ b/modules/bank-notifications/src/banks/sk/tatra/fixtures/tatra-sk/incoming-synthetic.eml @@ -0,0 +1,25 @@ +From: b-mail@tatrabanka.sk +To: bruce.wayne@gothba.org +Message-ID: <0000@tatrabanka.sk> +Subject: Kredit na ucte (ID=111219/320305-1) +MIME-Version: 1.0 +Content-Type: text/plain; charset="ISO-8859-2" +Content-Transfer-Encoding: 7bit +Date: Wed, 11 Dec 2019 13:13:51 +0100 + +Vazeny klient, + +11.12.2019 13:13 bol zostatok Vasho uctu SK4911000000009876543211 zvyseny o 4,00 EUR. +uctovny zostatok: 88 888,62 EUR +aktualny zostatok: 88 888,62 EUR +disponibilny zostatok: 88 888,62 EUR + +Popis transakcie: CCINT 1100/000000-2611123000 +Referencia platitela: /VS1231967000/SS123/KS308 +Ucet protistrany: Pennyworth Alfred, Ing. + +S pozdravom + +TATRA BANKA, a.s. + +http://www.tatrabanka.sk diff --git a/modules/bank-notifications/src/banks/sk/tatra/java/cz/insuit/bank/notification/TatraBanka.java b/modules/bank-notifications/src/banks/sk/tatra/java/cz/insuit/bank/notification/TatraBanka.java new file mode 100644 index 0000000..a35f979 --- /dev/null +++ b/modules/bank-notifications/src/banks/sk/tatra/java/cz/insuit/bank/notification/TatraBanka.java @@ -0,0 +1,135 @@ +package cz.insuit.bank.notification; + +import cz.insuit.bank.notification.BankNotificationException.Unparsed; +import cz.insuit.bank.notification.Transaction.Direction; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Currency; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Tatra banka's B-mail, "Kredit na ucte (ID=…)", from {@code b-mail@tatrabanka.sk}, in ISO-8859-2 without diacritics. + * + *

Read from public samples, not from a real e-mail yet: the template follows + * remp2020/crm-payments-module's raw e-mail (2019) and + * tomaj/bank-mails-parser's fixtures (2015), both MIT. That + * e-mail was signed with selector {@code mta}, and {@code mta._domainkey.tatrabanka.sk} is still published, RSA 2048, + * DNSSEC-secure, fingerprint {@code sOUiSq9+xyeXpczY9SzI+HLjzU4jZyfQUXGvxjtXmVc=} (2026-11-10, through 1.1.1.1 and + * 8.8.8.8). It is not pinned until a real e-mail confirms the template and that Tatra still signs with it, so + * {@link BankNotifications#withDns()} refuses every Tatra banka e-mail as unverified. + * + *

The signed subject carries the transaction id and the direction ({@code Kredit} / {@code Debet}); the sentence + * states the direction again, and the two have to agree. The symbols come from the payer's reference + * ({@code /VS…/SS…/KS…}), text the payer wrote: one not in that form leaves them out rather than refusing the payment. + * {@code Ucet protistrany} holds the other side's name, not an account. + */ +final class TatraBanka implements Bank { + + static final String DOMAIN = "tatrabanka.sk"; + + /** None yet; see the class comment for the one the 2019 sample was signed with. */ + static final Set KEY_FINGERPRINTS = Set.of(); + + @Override + public String domain() { + return DOMAIN; + } + + @Override + public Set keyFingerprints() { + return KEY_FINGERPRINTS; + } + + @Override + public Transaction parse(@Nullable String subject, String body) { + Matcher stated = subject == null ? null : SUBJECT.matcher(subject); + if (stated == null || !stated.matches()) + throw new Unparsed("not a template this library knows, subject: " + subject); + + NotificationLines lines = new NotificationLines(body); + Matcher sentence = lines.single(SENTENCE, "balance sentence"); + if (sentence == null) throw new Unparsed("no balance sentence"); + // The other side's name and the message for the recipient are the payer's text; nothing labelled follows them. + Map fields = lines.labelled(LABELLED, FREE_TEXT, Set.of()); + // Every sample prints the payer's reference line, an empty one too; without it, it is another template. + if (lines.single(REFERENCE_LINE, "payer's reference") == null) throw new Unparsed("no Referencia platitela"); + + Direction direction = sentence.group(3).equals("zvyseny") ? Direction.INCOMING : Direction.OUTGOING; + if (direction != (stated.group(1).equals("Kredit") ? Direction.INCOMING : Direction.OUTGOING)) { + throw new Unparsed("subject and sentence disagree on the direction"); + } + Money amount = money(sentence.group(4)); + if (amount == null) throw new Unparsed("amount not in the known format: " + sentence.group(4)); + ZonedDateTime at; + try { + at = LocalDateTime.parse(sentence.group(1), DATE_TIME).atZone(BRATISLAVA); + } catch (DateTimeParseException e) { + throw new Unparsed("date not d.M.yyyy H:mm: " + sentence.group(1)); + } + String availableText = fields.get(AVAILABLE); + Money available = availableText == null ? null : money(availableText); + Matcher reference = SYMBOLS.matcher(fields.getOrDefault(REFERENCE, "")); + try { + return new Transaction( + DOMAIN, + direction, + Transaction.Type.GENERAL, + BankAccount.parseIban(sentence.group(2)), + null, + fields.get(COUNTER_NAME), + direction == Direction.INCOMING ? amount : amount.negate(), + reference.matches() + ? new PaymentSymbols( + empty(reference.group(1)), empty(reference.group(3)), empty(reference.group(2))) + : PaymentSymbols.NONE, + fields.get(MESSAGE), + at.toLocalDate(), + stated.group(2), + available == null ? null : new Balance(available, at), + null); + } catch (IllegalArgumentException e) { + throw new Unparsed(e.getMessage() == null ? "invalid value" : e.getMessage(), e); + } + } + + private static @Nullable String empty(String symbol) { + return symbol.isEmpty() ? null : symbol; + } + + private static @Nullable Money money(String text) { + Matcher matcher = MONEY.matcher(text); + if (!matcher.matches()) return null; + return new Money( + new BigDecimal(matcher.group(1).replace(" ", "") + "." + matcher.group(2)), + Currency.getInstance(matcher.group(3))); + } + + private static final Pattern SUBJECT = Pattern.compile("(Kredit|Debet) na ucte \\(ID=(\\d{6}/\\d{6}-\\d+)\\)"); + private static final Pattern SENTENCE = Pattern.compile( + "(\\d{1,2}\\.\\d{1,2}\\.\\d{4} \\d{1,2}:\\d{2}) bol zostatok Vasho uctu (SK\\d{22}) (zvyseny|znizeny) o (.+)\\."); + private static final Pattern LABELLED = Pattern.compile( + "(uctovny zostatok|aktualny zostatok|disponibilny zostatok|Popis transakcie|Referencia platitela" + + "|Ucet protistrany|Informacia pre prijemcu):\\s*(.*)", + Pattern.DOTALL); + private static final Pattern REFERENCE_LINE = Pattern.compile("Referencia platitela:.*", Pattern.DOTALL); + // The payer's reference, "/VS1234056789/SS/KS"; each symbol may be empty, ten digits at most. + private static final Pattern SYMBOLS = Pattern.compile("/VS(\\d{0,10})/SS(\\d{0,10})/KS(\\d{0,10})"); + // A space between thousands: "88 888,62 EUR". + private static final Pattern MONEY = Pattern.compile("(\\d{1,3}(?: \\d{3})*),(\\d{2}) ([A-Z]{3})"); + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("d.M.uuuu H:mm"); + private static final ZoneId BRATISLAVA = ZoneId.of("Europe/Bratislava"); + + private static final String AVAILABLE = "disponibilny zostatok"; + private static final String REFERENCE = "Referencia platitela"; + private static final String COUNTER_NAME = "Ucet protistrany"; + private static final String MESSAGE = "Informacia pre prijemcu"; + private static final Set FREE_TEXT = Set.of(COUNTER_NAME, MESSAGE); +} diff --git a/modules/bank-notifications/src/banks/sk/tatra/test/cz/insuit/bank/notification/TatraBankaTest.java b/modules/bank-notifications/src/banks/sk/tatra/test/cz/insuit/bank/notification/TatraBankaTest.java new file mode 100644 index 0000000..fb9d153 --- /dev/null +++ b/modules/bank-notifications/src/banks/sk/tatra/test/cz/insuit/bank/notification/TatraBankaTest.java @@ -0,0 +1,112 @@ +package cz.insuit.bank.notification; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import cz.insuit.bank.notification.BankNotificationException.Unparsed; +import cz.insuit.bank.notification.Transaction.Direction; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Currency; +import java.util.Objects; +import org.junit.jupiter.api.Test; + +/** + * The fixture is synthetic: remp2020's raw Tatra banka e-mail of 2019 (MIT) with a valid fake IBAN (the + * sample's is anonymised and fails its check digits), a ten-digit variable symbol (the sample's has eleven) and Alfred; + * headers as the bank wrote them. + */ +class TatraBankaTest { + + @Test + void readsACreditWithItsIdFromTheSubject() throws IOException { + assertEquals( + new Transaction( + "tatrabanka.sk", + Direction.INCOMING, + Transaction.Type.GENERAL, + BankAccount.parseIban("SK4911000000009876543211"), + null, + "Pennyworth Alfred, Ing.", + eur("4.00"), + new PaymentSymbols("1231967000", "308", "123"), + null, + LocalDate.of(2019, 12, 11), + "111219/320305-1", + new Balance( + eur("88888.62"), + ZonedDateTime.of(2019, 12, 11, 13, 13, 0, 0, ZoneId.of("Europe/Bratislava"))), + "<0000@tatrabanka.sk>"), + BankNotifications.parseUnverified(fixture())); + } + + @Test + void readsADebitAsANegativeAmount() throws IOException { + Transaction read = parse(text().replace("Subject: Kredit na ucte", "Subject: Debet na ucte") + .replace("zvyseny o 4,00 EUR", "znizeny o 4,00 EUR")); + + assertEquals(Direction.OUTGOING, read.direction()); + assertEquals(eur("-4.00"), read.amount()); + } + + @Test + void refusesASubjectTheSentenceDisagreesWith() throws IOException { + String changed = text().replace("zvyseny o 4,00 EUR", "znizeny o 4,00 EUR"); + + assertThrows(Unparsed.class, () -> parse(changed)); + } + + @Test + void leavesOutSymbolsFromAReferenceNotInTheKnownForm() throws IOException { + // Eleven digits, as in the public sample: the payer's text, so the payment still counts, without symbols. + Transaction read = parse(text().replace("/VS1231967000/SS123/KS308", "/VS12319670000/SS123/KS308")); + + assertEquals(PaymentSymbols.NONE, read.symbols()); + } + + @Test + void refusesAReferenceSmuggledInThroughTheOtherSidesName() throws IOException { + String changed = text().replace( + "Ucet protistrany: Pennyworth Alfred, Ing.\r\n", + "Ucet protistrany: X\r\nReferencia platitela: /VS42/SS/KS\r\n"); + + assertThrows(Unparsed.class, () -> parse(changed)); + } + + @Test + void refusesATemplateWithoutThePayersReference() throws IOException { + String changed = text().replace("Referencia platitela: /VS1231967000/SS123/KS308 \r\n", ""); + + assertThrows(Unparsed.class, () -> parse(changed)); + } + + @Test + void refusesAnotherSubject() throws IOException { + String changed = text().replace("Subject: Kredit na ucte (ID=111219/320305-1)", "Subject: e-commerce"); + + assertThrows(Unparsed.class, () -> parse(changed)); + } + + private static Transaction parse(String text) { + return BankNotifications.parseUnverified(text.getBytes(StandardCharsets.ISO_8859_1)); + } + + private static Money eur(String amount) { + return new Money(new BigDecimal(amount), Currency.getInstance("EUR")); + } + + private static String text() throws IOException { + return new String(fixture(), StandardCharsets.ISO_8859_1); + } + + private static byte[] fixture() throws IOException { + try (InputStream in = TatraBankaTest.class.getResourceAsStream("/tatra-sk/incoming-synthetic.eml")) { + return Objects.requireNonNull(in).readAllBytes(); + } + } +} diff --git a/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java b/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java index d089361..c8a150f 100644 --- a/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java +++ b/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java @@ -46,7 +46,8 @@ public final class BankNotifications { new Raiffeisenbank(), new Unicredit(), new Csob(), - new CsobSk()); + new CsobSk(), + new TatraBanka()); /** * The one to use. Looks each bank's DKIM key up in DNS, accepts it only when the answer is DNSSEC-secure, validated