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
2 changes: 1 addition & 1 deletion .docs/banks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions .docs/banks/sk/tatra/README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
*
* <p><b>Read from public samples, not from a real e-mail yet</b>: the template follows
* <a href="https://github.com/remp2020/crm-payments-module">remp2020/crm-payments-module</a>'s raw e-mail (2019) and
* <a href="https://github.com/tomaj/bank-mails-parser">tomaj/bank-mails-parser</a>'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.
*
* <p>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<String> KEY_FINGERPRINTS = Set.of();

@Override
public String domain() {
return DOMAIN;
}

@Override
public Set<String> 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<String, String> 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<String> FREE_TEXT = Set.of(COUNTER_NAME, MESSAGE);
}
Original file line number Diff line number Diff line change
@@ -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 <b>synthetic</b>: 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading