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