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 @@ -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) |
Expand Down
36 changes: 33 additions & 3 deletions .docs/banks/cz/csob/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 →
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Date: Sun, 31 May 2026 10:00:00 +0200
From: =?utf-8?Q?=C4=8CSOB?= <noreply@csob.cz>
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Date: Sun, 31 May 2026 10:00:00 +0200
From: =?utf-8?Q?=C4=8CSOB?= <noreply@csob.cz>
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public Transaction parse(@Nullable String subject, String body) {

@Override
public List<Transaction> parseAll(@Nullable String subject, String body) {
if (OWN_BANKING_SUBJECT.equals(subject)) return List.of(ownBanking(body));
List<List<String>> blocks = blocks(body);
if (blocks.isEmpty()) throw new Unparsed("no transaction sentence");
List<Transaction> transactions = new ArrayList<>();
Expand Down Expand Up @@ -155,6 +156,74 @@ private static Block block(List<String> 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<String, String> 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);
Expand All @@ -179,6 +248,23 @@ private static Block block(List<String> 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<String> 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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
Expand Down
Loading