From 63087d97c58717b63dc520193c4a97d09a4067c1 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 23:28:12 +0100 Subject: [PATCH] be: read csob's moje info notice from public samples, beside its ceb one --- .docs/banks/README.md | 2 +- .docs/banks/cz/csob/README.md | 36 +++++++- .../own-banking-incoming-synthetic.eml | 27 ++++++ .../own-banking-outgoing-synthetic.eml | 29 +++++++ .../cz/insuit/bank/notification/Csob.java | 86 +++++++++++++++++++ .../cz/insuit/bank/notification/CsobTest.java | 62 +++++++++++++ .../bank/notification/NotificationLines.java | 12 ++- .../notification/NotificationLinesTest.java | 12 +++ 8 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-incoming-synthetic.eml create mode 100644 modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-outgoing-synthetic.eml diff --git a/.docs/banks/README.md b/.docs/banks/README.md index 59801a6..0776086 100644 --- a/.docs/banks/README.md +++ b/.docs/banks/README.md @@ -23,7 +23,7 @@ A bank is in one of three states: | Creditas | samples only | incoming and outgoing; counter-account, symbol, balance | transaction id, name, message; card holds refused | [creditas](cz/creditas/README.md) | | Raiffeisenbank | samples only | transfers and card payments, from HTML; names, symbols, message, balance | transaction id | [raiffeisenbank](cz/raiffeisenbank/README.md) | | UniCredit Bank | samples only | incoming, from HTML; counter-account and name, symbols, message | transaction id, balance, type; how an outgoing payment writes its direction | [unicredit](cz/unicredit/README.md) | -| ČSOB | samples only | CEB: incoming transfers and card payments, several in one e-mail (`readAll`); names, symbols, message | transaction id, balance time; SEPA, foreign and cash payments; its own internet banking's newer notification | [csob](cz/csob/README.md) | +| ČSOB | samples only | CEB: incoming transfers and card payments, several in one e-mail (`readAll`); own banking's "Moje info - Avízo", in and out; names, symbols, message | transaction id, balance time; SEPA, foreign and cash payments | [csob](cz/csob/README.md) | | Komerční banka | not yet | | | [#14](https://github.com/landsman/bank-java/issues/14) | | Česká spořitelna | not yet | | | [#15](https://github.com/landsman/bank-java/issues/15) | | Citfin | not yet | | | [#21](https://github.com/landsman/bank-java/issues/21) | diff --git a/.docs/banks/cz/csob/README.md b/.docs/banks/cz/csob/README.md index a882c0c..7c82d82 100644 --- a/.docs/banks/cz/csob/README.md +++ b/.docs/banks/cz/csob/README.md @@ -7,9 +7,9 @@ them, 2018 to 2022), whose accounts fail the modulo 11 check. ČSOB signed as `d none is pinned and `withDns()` refuses every ČSOB e-mail as `Unverified`. See [#16](https://github.com/landsman/bank-java/issues/16). -The newer notification of ČSOB's own internet banking (labels on their own lines, `Parametry platby`, as in -[myinvoice's sample](https://github.com/radekhulan/myinvoice/blob/master/api/tests/Unit/Bank/CsobBankEmailNoticeParserTest.php)) -is not read yet. +ČSOB's own internet banking sends another template, **"Moje info - Avízo"**, read from +[myinvoice's samples](https://github.com/radekhulan/myinvoice/blob/master/api/tests/Unit/Bank/CsobBankEmailNoticeParserTest.php) +(MIT); see [below](#moje-info---avízo). ## What it carries @@ -45,6 +45,36 @@ Zůstatek na účtu po zaúčtování transakce: +1 234 567,89 CZK. - **No transaction id**, and the balance has no time, so `balance()` is `null`. Deduplicate on a signed `messageId()` (ČSOB signed `message-id` in 2020) and the position in `readAll()`'s list. +## Moje info - Avízo + +The subject picks it. One transaction, the labels on lines of their own and each value under its label: + +``` +dne 31.5.2026 byla na účtu 123456789 zaúčtována transakce typu: Odchozí úhrada okamžitá. + +Parametry platby +Účet +123456789/0300 +Účet protistrany +35-4544230267/0100 +Datum účtování +31.5.2026 +Částka +-10 000,00 CZK +Variabilní symbol +0009002567593 +Konstantní symbol +1120 +Zůstatek na účtu po zaúčtování transakce: +0,00 CZK. +``` + +- **The direction is stated twice**, `Příchozí` / `Odchozí` in the kind and the amount's sign, and has to agree. +- **A symbol may be padded with zeros to 13 digits**; only the zeros beyond ten are cut, so `0000002567593` is + `0002567593`. A longer symbol that is not zero padding is refused. +- **The line under `Název protistrany` is the name; the one after it has to be `Datum účtování`**, so a line break in + the name cannot add lines. +- No transaction id, no message label seen; the balance has no time. + ## Setting it up [Fakturoid's guide](https://www.fakturoid.cz/podpora/parovani/csob): in CEB, Nastavení → CEB info → Účty a platby → diff --git a/modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-incoming-synthetic.eml b/modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-incoming-synthetic.eml new file mode 100644 index 0000000..20f89be --- /dev/null +++ b/modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-incoming-synthetic.eml @@ -0,0 +1,27 @@ +Date: Sun, 31 May 2026 10:00:00 +0200 +From: =?utf-8?Q?=C4=8CSOB?= +To: bruce.wayne@gothba.org +Message-ID: <0001@csob.cz> +Subject: =?utf-8?q?Moje_info_-_Av=C3=ADzo?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit + +Dobrý den, +dne 31.5.2026 byla na účtu 1234567899 zaúčtována transakce typu: Příchozí úhrada okamžitá. + +Parametry platby +Účet +1234567899/0300 +Účet protistrany +9876543211/2700 +Název protistrany +WAYNE ENTERPRISES +Datum účtování +31.5.2026 +Částka ++10 000,00 CZK +Zůstatek na účtu po zaúčtování transakce: +10 000,00 CZK. + +Přejeme příjemný den. +Vaše ČSOB diff --git a/modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-outgoing-synthetic.eml b/modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-outgoing-synthetic.eml new file mode 100644 index 0000000..8c7a986 --- /dev/null +++ b/modules/bank-notifications/src/banks/cz/csob/fixtures/csob-cz/own-banking-outgoing-synthetic.eml @@ -0,0 +1,29 @@ +Date: Sun, 31 May 2026 10:00:00 +0200 +From: =?utf-8?Q?=C4=8CSOB?= +To: bruce.wayne@gothba.org +Message-ID: <0001@csob.cz> +Subject: =?utf-8?q?Moje_info_-_Av=C3=ADzo?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit + +Dobrý den, +dne 31.5.2026 byla na účtu 1234567899 zaúčtována transakce typu: Odchozí úhrada okamžitá. + +Parametry platby +Účet +1234567899/0300 +Účet protistrany +19-2000145399/0800 +Datum účtování +31.5.2026 +Částka +-10 000,00 CZK +Variabilní symbol +0009002567593 +Konstantní symbol +1120 +Zůstatek na účtu po zaúčtování transakce: +0,00 CZK. + +Přejeme příjemný den. +Vaše ČSOB diff --git a/modules/bank-notifications/src/banks/cz/csob/java/cz/insuit/bank/notification/Csob.java b/modules/bank-notifications/src/banks/cz/csob/java/cz/insuit/bank/notification/Csob.java index 61ea67f..1b031ef 100644 --- a/modules/bank-notifications/src/banks/cz/csob/java/cz/insuit/bank/notification/Csob.java +++ b/modules/bank-notifications/src/banks/cz/csob/java/cz/insuit/bank/notification/Csob.java @@ -55,6 +55,7 @@ public Transaction parse(@Nullable String subject, String body) { @Override public List parseAll(@Nullable String subject, String body) { + if (OWN_BANKING_SUBJECT.equals(subject)) return List.of(ownBanking(body)); List> blocks = blocks(body); if (blocks.isEmpty()) throw new Unparsed("no transaction sentence"); List transactions = new ArrayList<>(); @@ -155,6 +156,74 @@ private static Block block(List block) { } } + /** + * The notification of ČSOB's own internet banking, "Moje info - Avízo": one transaction, the labels on lines of their + * own and each value under its label. Read from radekhulan/myinvoice's samples (MIT), not a real e-mail. The + * direction is stated twice, in the kind ({@code Příchozí} / {@code Odchozí}) and the amount's sign. + */ + private static Transaction ownBanking(String body) { + NotificationLines lines = new NotificationLines(body); + Matcher sentence = lines.single(OWN_SENTENCE, "transaction sentence"); + if (sentence == null) throw new Unparsed("no transaction sentence"); + // The counter-party's name is the line under its label; the booking date's label comes right after it. + lines.requireAt(OWN_NAME_LABEL, 2, OWN_BOOKED); + Map fields = lines.stacked(OWN_LABELS, Set.of(), Set.of()); + + String ownText = fields.get(ACCOUNT); + Matcher own = ownText == null ? null : OWN_BANKING_ACCOUNT.matcher(ownText); + if (own == null || !own.matches()) throw new Unparsed("Účet missing or not in the known format"); + if (!own.group(1).equals(sentence.group(2))) + throw new Unparsed("the sentence's account is not the Účet line's"); + String amountText = fields.get(AMOUNT); + Money amount = amountText == null ? null : signed(amountText); + if (amount == null) throw new Unparsed("amount missing or not in the known format"); + Direction direction = sentence.group(3).equals("Příchozí") ? Direction.INCOMING : Direction.OUTGOING; + if ((direction == Direction.INCOMING) != (amount.amount().signum() > 0)) { + throw new Unparsed("the kind and the amount's sign disagree on the direction"); + } + String booked = fields.get(OWN_BOOKED); + if (booked == null) throw new Unparsed("no Datum účtování"); + LocalDate bookedOn; + try { + bookedOn = LocalDate.parse(booked, DATE); + } catch (DateTimeParseException e) { + throw new Unparsed("Datum účtování not d.M.yyyy: " + booked); + } + String counter = fields.get(COUNTER_ACCOUNT); + try { + return new Transaction( + DOMAIN, + direction, + Transaction.Type.TRANSFER, + BankAccount.parse(own.group(1) + "/" + BANK_CODE), + counter == null ? null : BankAccount.parse(counter), + fields.get(COUNTER_NAME), + amount, + new PaymentSymbols( + PaymentSymbols.checked(VARIABLE_SYMBOL, unpadded(fields.get(VARIABLE_SYMBOL))), + PaymentSymbols.checked(CONSTANT_SYMBOL, unpadded(fields.get(CONSTANT_SYMBOL))), + PaymentSymbols.checked(SPECIFIC_SYMBOL, unpadded(fields.get(SPECIFIC_SYMBOL)))), + null, + bookedOn, + null, + null, + null); + } catch (IllegalArgumentException e) { + throw new Unparsed(e.getMessage() == null ? "invalid value" : e.getMessage(), e); + } + } + + /** + * A symbol as this template prints it, padded with zeros to 13 digits in the sample ({@code 0009002567593}); a symbol + * has 10 at most. Only the zeros beyond ten are cut, so zeros that belong to the symbol stay; anything else is left + * for {@link PaymentSymbols#checked} to refuse. + */ + private static @Nullable String unpadded(@Nullable String symbol) { + if (symbol == null || symbol.length() <= 10) return symbol; + String excess = symbol.substring(0, symbol.length() - 10); + return excess.chars().allMatch(c -> c == '0') ? symbol.substring(symbol.length() - 10) : symbol; + } + /** A signed amount, {@code +1 234,56 CZK}: positive for money in, negative for money out. */ private static @Nullable Money signed(String text) { Matcher matcher = MONEY.matcher(text); @@ -179,6 +248,23 @@ private static Block block(List block) { // A sign, a space between thousands: "+1 234,56 CZK". private static final Pattern MONEY = Pattern.compile("([+-])(\\d{1,3}(?: \\d{3})*),(\\d{2}) ([A-Z]{3})"); private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("d.M.uuuu"); + + private static final String OWN_BANKING_SUBJECT = "Moje info - Avízo"; + private static final Pattern OWN_SENTENCE = + Pattern.compile("dne (\\d{1,2}\\.\\d{1,2}\\.\\d{4}) byla na účtu (\\d{2,10}) zaúčtována transakce typu:" + + " (Příchozí|Odchozí) úhrada(?: okamžitá)?\\."); + private static final Pattern OWN_BANKING_ACCOUNT = Pattern.compile("(\\d{2,10})/0300"); + private static final Pattern OWN_NAME_LABEL = Pattern.compile("Název protistrany"); + private static final String OWN_BOOKED = "Datum účtování"; + private static final Set OWN_LABELS = Set.of( + "Účet", + "Účet protistrany", + "Název protistrany", + OWN_BOOKED, + "Částka", + "Variabilní symbol", + "Konstantní symbol", + "Specifický symbol"); private static final String BANK_CODE = "0300"; private static final String CARD = "platební kartou"; diff --git a/modules/bank-notifications/src/banks/cz/csob/test/cz/insuit/bank/notification/CsobTest.java b/modules/bank-notifications/src/banks/cz/csob/test/cz/insuit/bank/notification/CsobTest.java index 3b4a16e..bd7da04 100644 --- a/modules/bank-notifications/src/banks/cz/csob/test/cz/insuit/bank/notification/CsobTest.java +++ b/modules/bank-notifications/src/banks/cz/csob/test/cz/insuit/bank/notification/CsobTest.java @@ -133,6 +133,68 @@ void refusesAnAmountInAnotherCurrencyThanTheAccount() throws IOException { assertThrows(Unparsed.class, () -> BankNotifications.parseUnverified(bytes(changed))); } + /** Synthetic: myinvoice's sanitised samples of "Moje info - Avízo" (MIT), with this repository's accounts. */ + @Test + void readsTheOwnBankingNotice() throws IOException { + assertEquals( + new Transaction( + "csob.cz", + Direction.INCOMING, + Transaction.Type.TRANSFER, + BankAccount.parse("1234567899/0300"), + BankAccount.parse("9876543211/2700"), + "WAYNE ENTERPRISES", + czk("10000.00"), + PaymentSymbols.NONE, + null, + LocalDate.of(2026, 5, 31), + null, + null, + "<0001@csob.cz>"), + BankNotifications.parseUnverified(fixture("own-banking-incoming-synthetic.eml"))); + } + + @Test + void readsAnOutgoingOwnBankingNoticeAndCutsOnlyTheZerosPaddingASymbol() throws IOException { + Transaction out = BankNotifications.parseUnverified(fixture("own-banking-outgoing-synthetic.eml")); + + assertEquals(Direction.OUTGOING, out.direction()); + assertEquals(czk("-10000.00"), out.amount()); + assertEquals(new PaymentSymbols("9002567593", "1120", null), out.symbols()); + } + + @Test + void keepsTheZerosThatBelongToAPaddedSymbol() throws IOException { + String changed = text("own-banking-outgoing-synthetic.eml").replace("0009002567593", "0000002567593"); + + assertEquals( + "0002567593", + BankNotifications.parseUnverified(bytes(changed)).symbols().variable()); + } + + @Test + void refusesASymbolLongerThanTenThatIsNotZeroPadding() throws IOException { + String changed = text("own-banking-outgoing-synthetic.eml").replace("0009002567593", "1009002567593"); + + assertThrows(Unparsed.class, () -> BankNotifications.parseUnverified(bytes(changed))); + } + + @Test + void refusesAnOwnBankingKindTheSignDisagreesWith() throws IOException { + String changed = text("own-banking-incoming-synthetic.eml") + .replace("Částka\r\n+10 000,00 CZK", "Částka\r\n-10 000,00 CZK"); + + assertThrows(Unparsed.class, () -> BankNotifications.parseUnverified(bytes(changed))); + } + + @Test + void refusesASymbolSmuggledInThroughTheOwnBankingCounterPartysName() throws IOException { + String changed = text("own-banking-incoming-synthetic.eml") + .replace("WAYNE ENTERPRISES\r\n", "EVIL\r\nVariabilní symbol\r\n42\r\nX\r\n"); + + assertThrows(Unparsed.class, () -> BankNotifications.parseUnverified(bytes(changed))); + } + private static Money czk(String amount) { return new Money(new BigDecimal(amount), Currency.getInstance("CZK")); } diff --git a/modules/bank-notifications/src/parsing/java/cz/insuit/bank/notification/NotificationLines.java b/modules/bank-notifications/src/parsing/java/cz/insuit/bank/notification/NotificationLines.java index 96b7994..a5ee8ce 100644 --- a/modules/bank-notifications/src/parsing/java/cz/insuit/bank/notification/NotificationLines.java +++ b/modules/bank-notifications/src/parsing/java/cz/insuit/bank/notification/NotificationLines.java @@ -52,10 +52,18 @@ Matcher single(Pattern pattern, String what) { * would add lines, a counter-party or a variable symbol of its own, and push {@code next} down. */ void requireNext(Pattern line, String next) { + requireAt(line, 1, next); + } + + /** + * Like {@link #requireNext}, {@code offset} lines down: for a template that puts the name on the line under its + * label, the line after the name is two below the label. + */ + void requireAt(Pattern line, int offset, String expected) { for (int i = 0; i < lines.size(); i++) { if (!line.matcher(lines.get(i)).matches()) continue; - if (i + 1 >= lines.size() || !lines.get(i + 1).startsWith(next)) { - throw new Unparsed("'" + next + "' does not follow the line naming the other side"); + if (i + offset >= lines.size() || !lines.get(i + offset).startsWith(expected)) { + throw new Unparsed("'" + expected + "' is not where the template puts it after the other side's name"); } } } diff --git a/modules/bank-notifications/src/parsing/test/cz/insuit/bank/notification/NotificationLinesTest.java b/modules/bank-notifications/src/parsing/test/cz/insuit/bank/notification/NotificationLinesTest.java index 0c09acb..f719135 100644 --- a/modules/bank-notifications/src/parsing/test/cz/insuit/bank/notification/NotificationLinesTest.java +++ b/modules/bank-notifications/src/parsing/test/cz/insuit/bank/notification/NotificationLinesTest.java @@ -86,6 +86,18 @@ void requireNextRefusesTheLineBeingLast() { assertThrows(Unparsed.class, () -> lines.requireNext(Pattern.compile("Od: .*"), "Částka:")); } + @Test + void requireAtLooksTheGivenNumberOfLinesDown() { + new NotificationLines("Název\r\nJan\r\nDatum").requireAt(Pattern.compile("Název"), 2, "Datum"); + } + + @Test + void requireAtRefusesALineSmuggledInBetween() { + NotificationLines lines = new NotificationLines("Název\r\nJan\r\nVS\r\n42\r\nDatum"); + + assertThrows(Unparsed.class, () -> lines.requireAt(Pattern.compile("Název"), 2, "Datum")); + } + @Test void requireNextIgnoresABodyWithoutTheLine() { new NotificationLines("Částka: 1").requireNext(Pattern.compile("Od: .*"), "Částka:");