From 47c5fab86e7f10139faacf374afe9c878cef55ab Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Wed, 11 Nov 2026 23:35:11 +0100 Subject: [PATCH 01/10] be: find a token's account by its iban, so a caller with several accounts needs no moneta id --- .docs/usage.md | 25 ++++++++++++ .../java/cz/insuit/moneta/AccountHolder.java | 25 ++++++++++++ .../cz/insuit/moneta/MonetaClientTest.java | 39 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/.docs/usage.md b/.docs/usage.md index 27f76e7..2ff3e1e 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -36,6 +36,31 @@ me.tokenExpiry() // when the token stops working: ask the With the Spring Boot starter, `moneta.token` gives you the `AccessToken` bean. +## A token with several accounts + +`me.transactions()` and `me.balance()` read the only account a token opens and refuse a token with more. Then +find the one you stored by its IBAN, which is what the merchant gave you; MONETA's own account id is known to +nobody outside it. + +```java +var account = me.account(storedIban); // spaces and case do not matter +if (account == null) { + return refuse(); // the token no longer sees it: revoked, or another token pasted +} +me.transactions(account.id()).between(from, to).list(); +``` + +
Kotlin + +```kotlin +val account = me.account(storedIban) ?: return refuse() +me.transactions(account.id()).between(from, to).list() +``` + +
+ +Each call reads the accounts again, so an account revoked for the token shows on the next one. + ## Filtering | Step | Keeps | diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java index 0c7aa71..bb828ea 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.util.List; +import java.util.Locale; import java.util.concurrent.CancellationException; import org.jspecify.annotations.Nullable; @@ -68,6 +69,30 @@ public Instant tokenExpiry() { return client.tokenExpiry(token); } + /** + * The account with this IBAN, compared without spaces and case, so {@code cz02 0600 …} as printed matches; an + * account without an IBAN never does. Reads {@link #accounts()} on every call and remembers nothing, so an + * account the token stopped seeing shows on the next one. + * + * @return null when the token does not see it: revoked for that account, or another holder's token + * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes + * @throws CancellationException when the thread is interrupted; its interrupt flag stays set + */ + public @Nullable Account account(String iban) { + var wanted = normalised(requireNonNull(iban, "iban")); + for (var account : accounts()) { + var own = account.iban(); + if (own != null && normalised(own).equals(wanted)) { + return account; + } + } + return null; + } + + private static String normalised(String iban) { + return iban.replaceAll("\\s", "").toUpperCase(Locale.ROOT); + } + private String onlyAccountId() { var id = onlyAccountId; if (id == null) { diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index 2c4699e..baf8e28 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -1,5 +1,6 @@ package cz.insuit.moneta; +import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -71,6 +72,13 @@ class MonetaClientTest { // language=JSON private static final String ONE_ACCOUNT = """ {"accounts": [{"id": "a", "currency": "CZK"}]}"""; + // An IBAN-less account first, so a match cannot come from comparing against a missing one. + // language=JSON + private static final String TWO_ACCOUNTS_ONE_WITHOUT_IBAN = """ + {"accounts": [ + {"id": "acc-0", "currency": "EUR"}, + {"id": "acc-1", "currency": "CZK", "identification": {"iban": "CZ0206000000000000001337"}} + ], "pageNumber": 0, "pageCount": 1}"""; // language=JSON private static final String NO_ACCOUNTS = """ {"accounts": []}"""; @@ -519,6 +527,37 @@ void aHolderAsksForItsOnlyAccountOnce() { .count()); } + @Test + void aHolderFindsAnAccountByItsIbanAsPrinted() { + answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); + + var found = client.with(TOKEN).account("cz02 0600 0000 0000 0000 1337"); + + assertEquals("acc-1", requireNonNull(found).id()); + } + + @Test + void anIbanTheTokenDoesNotSeeIsNullNotAnError() { + answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); + + assertNull(client.with(TOKEN).account("CZ4008000000004040404040")); + } + + @Test + void aHolderReadsTheAccountsAgainForEveryIban() { + answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); + var me = client.with(TOKEN); + + me.account("CZ0206000000000000001337"); + me.account("CZ0206000000000000001337"); + + assertEquals( + 2, + requests.stream() + .filter(r -> r.getRequestURI().getPath().equals(ACCOUNTS)) + .count()); + } + @Test void aQueryWithoutAnAccountRefusesATokenWithTwo() { answer(ACCOUNTS, 200, """ From c8ba4b00515a541ee2bea01d8337bba658a57ba4 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 08:56:05 +0100 Subject: [PATCH 02/10] be: let a client without a holder find an account by its iban --- .../java/cz/insuit/moneta/AccountHolder.java | 24 ++---------------- .../java/cz/insuit/moneta/MonetaClient.java | 25 +++++++++++++++++++ .../cz/insuit/moneta/MonetaClientTest.java | 10 ++++++++ 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java index bb828ea..e19fdb2 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java @@ -4,7 +4,6 @@ import java.time.Instant; import java.util.List; -import java.util.Locale; import java.util.concurrent.CancellationException; import org.jspecify.annotations.Nullable; @@ -69,28 +68,9 @@ public Instant tokenExpiry() { return client.tokenExpiry(token); } - /** - * The account with this IBAN, compared without spaces and case, so {@code cz02 0600 …} as printed matches; an - * account without an IBAN never does. Reads {@link #accounts()} on every call and remembers nothing, so an - * account the token stopped seeing shows on the next one. - * - * @return null when the token does not see it: revoked for that account, or another holder's token - * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes - * @throws CancellationException when the thread is interrupted; its interrupt flag stays set - */ + /** As {@link MonetaClient#account(AccessToken, String)}. */ public @Nullable Account account(String iban) { - var wanted = normalised(requireNonNull(iban, "iban")); - for (var account : accounts()) { - var own = account.iban(); - if (own != null && normalised(own).equals(wanted)) { - return account; - } - } - return null; - } - - private static String normalised(String iban) { - return iban.replaceAll("\\s", "").toUpperCase(Locale.ROOT); + return client.account(token, iban); } private String onlyAccountId() { diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java index 9ad9e73..fa383e2 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.concurrent.CancellationException; import java.util.function.Consumer; import java.util.function.Function; @@ -198,6 +199,30 @@ public TransactionQuery transactions(AccessToken token, String accountId) { return new TransactionQuery(this, token, () -> accountId); } + /** + * The account with this IBAN, compared without spaces and case, so {@code cz02 0600 …} as printed matches; an + * account without an IBAN never does. Reads {@link #accounts(AccessToken)} on every call and remembers nothing, + * so an account the token stopped seeing shows on the next one. + * + * @return null when the token does not see it: revoked for that account, or another holder's token + * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes + * @throws CancellationException when the thread is interrupted; its interrupt flag stays set + */ + public @Nullable Account account(AccessToken token, String iban) { + var wanted = normalisedIban(requireNonNull(iban, "iban")); + for (var account : accounts(token)) { + var own = account.iban(); + if (own != null && normalisedIban(own).equals(wanted)) { + return account; + } + } + return null; + } + + private static String normalisedIban(String iban) { + return iban.replaceAll("\\s", "").toUpperCase(Locale.ROOT); + } + /** The id of the only account the token opens; {@link IllegalStateException} unless there is exactly one. */ String onlyAccount(AccessToken token) { var accounts = accounts(token); diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index baf8e28..f22a44f 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -543,6 +543,16 @@ void anIbanTheTokenDoesNotSeeIsNullNotAnError() { assertNull(client.with(TOKEN).account("CZ4008000000004040404040")); } + @Test + void theClientFindsAnAccountByIbanWithoutAHolder() { + answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); + + assertEquals( + "acc-1", + requireNonNull(client.account(TOKEN, "CZ0206000000000000001337")) + .id()); + } + @Test void aHolderReadsTheAccountsAgainForEveryIban() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); From 82b6da4936c6a0569e3e47d4f25b0314408a2e37 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 08:57:09 +0100 Subject: [PATCH 03/10] be: match an iban copied with no-break spaces, which a pdf or a web page puts in --- .docs/usage.md | 2 +- .../src/main/java/cz/insuit/moneta/MonetaClient.java | 7 ++++++- .../src/test/java/cz/insuit/moneta/MonetaClientTest.java | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index 2ff3e1e..1e80317 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -43,7 +43,7 @@ find the one you stored by its IBAN, which is what the merchant gave you; MONETA nobody outside it. ```java -var account = me.account(storedIban); // spaces and case do not matter +var account = me.account(storedIban); // spaces of any kind and case do not matter; dashes do if (account == null) { return refuse(); // the token no longer sees it: revoked, or another token pasted } diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java index fa383e2..81b49d5 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java @@ -219,8 +219,13 @@ public TransactionQuery transactions(AccessToken token, String accountId) { return null; } + /** Without any space, a no-break one copied from a PDF or a web page included; other characters are kept. */ private static String normalisedIban(String iban) { - return iban.replaceAll("\\s", "").toUpperCase(Locale.ROOT); + var kept = new StringBuilder(iban.length()); + iban.codePoints() + .filter(c -> !Character.isWhitespace(c) && !Character.isSpaceChar(c)) + .forEach(kept::appendCodePoint); + return kept.toString().toUpperCase(Locale.ROOT); } /** The id of the only account the token opens; {@link IllegalStateException} unless there is exactly one. */ diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index f22a44f..a7ce6e9 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -543,6 +543,15 @@ void anIbanTheTokenDoesNotSeeIsNullNotAnError() { assertNull(client.with(TOKEN).account("CZ4008000000004040404040")); } + @Test + void aNoBreakSpaceCopiedFromAStatementMatchesToo() { + answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); + + var found = client.account(TOKEN, "CZ02 0600 0000\t0000 0000 1337"); + + assertEquals("acc-1", requireNonNull(found).id()); + } + @Test void theClientFindsAnAccountByIbanWithoutAHolder() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); From f2d7b9e8f1c0aa6706366de8a2d6dd9af2e86383 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 08:58:09 +0100 Subject: [PATCH 04/10] be: refuse a blank iban, so a caller's empty value is not read as a revoked account --- .../src/main/java/cz/insuit/moneta/MonetaClient.java | 5 +++++ .../src/test/java/cz/insuit/moneta/MonetaClientTest.java | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java index 81b49d5..26a9f37 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java @@ -205,11 +205,16 @@ public TransactionQuery transactions(AccessToken token, String accountId) { * so an account the token stopped seeing shows on the next one. * * @return null when the token does not see it: revoked for that account, or another holder's token + * @throws IllegalArgumentException when the IBAN is blank * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes * @throws CancellationException when the thread is interrupted; its interrupt flag stays set */ public @Nullable Account account(AccessToken token, String iban) { var wanted = normalisedIban(requireNonNull(iban, "iban")); + if (wanted.isEmpty()) { + // Null would read as "revoked", and a caller may disconnect the merchant over its own empty value. + throw new IllegalArgumentException("iban is blank"); + } for (var account : accounts(token)) { var own = account.iban(); if (own != null && normalisedIban(own).equals(wanted)) { diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index a7ce6e9..bae9fb5 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -552,6 +552,14 @@ void aNoBreakSpaceCopiedFromAStatementMatchesToo() { assertEquals("acc-1", requireNonNull(found).id()); } + @ParameterizedTest + @ValueSource(strings = {"", " ", " "}) + void aBlankIbanIsTheCallersMistakeNotARevokedAccount(String blank) { + answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); + + assertThrows(IllegalArgumentException.class, () -> client.account(TOKEN, blank)); + } + @Test void theClientFindsAnAccountByIbanWithoutAHolder() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); From 62b4564de85e34f9bcab7d158ab34266c97662d6 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 08:59:20 +0100 Subject: [PATCH 05/10] be: refuse an iban that two accounts share, rather than read one currency for another --- .../java/cz/insuit/moneta/MonetaClient.java | 17 +++++++++++------ .../java/cz/insuit/moneta/MonetaClientTest.java | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java index 26a9f37..b0fb663 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java @@ -206,6 +206,7 @@ public TransactionQuery transactions(AccessToken token, String accountId) { * * @return null when the token does not see it: revoked for that account, or another holder's token * @throws IllegalArgumentException when the IBAN is blank + * @throws IllegalStateException when more than one account has that IBAN, as one per currency could * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes * @throws CancellationException when the thread is interrupted; its interrupt flag stays set */ @@ -215,13 +216,17 @@ public TransactionQuery transactions(AccessToken token, String accountId) { // Null would read as "revoked", and a caller may disconnect the merchant over its own empty value. throw new IllegalArgumentException("iban is blank"); } - for (var account : accounts(token)) { - var own = account.iban(); - if (own != null && normalisedIban(own).equals(wanted)) { - return account; - } + var matches = accounts(token).stream() + .filter(account -> + account.iban() != null && normalisedIban(account.iban()).equals(wanted)) + .toList(); + if (matches.size() > 1) { + // A multi-currency account may list one entry per currency under one IBAN; picking one would read the + // wrong currency without an error. + throw new IllegalStateException("the token opens " + matches.size() + " accounts with that IBAN (" + + matches.stream().map(Account::id).toList() + "), so name one by its id"); } - return null; + return matches.isEmpty() ? null : matches.getFirst(); } /** Without any space, a no-break one copied from a PDF or a web page included; other characters are kept. */ diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index bae9fb5..34c1099 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -560,6 +560,20 @@ void aBlankIbanIsTheCallersMistakeNotARevokedAccount(String blank) { assertThrows(IllegalArgumentException.class, () -> client.account(TOKEN, blank)); } + @Test + void anIbanOnTwoAccountsIsRefusedRatherThanGuessed() { + // language=JSON + answer(ACCOUNTS, 200, """ + {"accounts": [ + {"id": "acc-czk", "currency": "CZK", "identification": {"iban": "CZ0206000000000000001337"}}, + {"id": "acc-eur", "currency": "EUR", "identification": {"iban": "CZ0206000000000000001337"}} + ], "pageNumber": 0, "pageCount": 1}"""); + + var refused = + assertThrows(IllegalStateException.class, () -> client.account(TOKEN, "CZ0206000000000000001337")); + assertTrue(String.valueOf(refused.getMessage()).contains("acc-eur"), refused.getMessage()); + } + @Test void theClientFindsAnAccountByIbanWithoutAHolder() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); From 783c6e27ccca8f468196231157e76c45a38a39ef Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 19:38:56 +0100 Subject: [PATCH 06/10] be: name the lookup accountByIban, so an account id passed to it is not read as revoked --- .docs/usage.md | 4 +- .../java/cz/insuit/moneta/AccountHolder.java | 6 +- .../java/cz/insuit/moneta/MonetaClient.java | 78 +++++++++---------- .../cz/insuit/moneta/MonetaClientTest.java | 18 ++--- 4 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index 1e80317..a17d061 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -43,7 +43,7 @@ find the one you stored by its IBAN, which is what the merchant gave you; MONETA nobody outside it. ```java -var account = me.account(storedIban); // spaces of any kind and case do not matter; dashes do +var account = me.accountByIban(storedIban); // spaces of any kind and case do not matter; dashes do if (account == null) { return refuse(); // the token no longer sees it: revoked, or another token pasted } @@ -53,7 +53,7 @@ me.transactions(account.id()).between(from, to).list();
Kotlin ```kotlin -val account = me.account(storedIban) ?: return refuse() +val account = me.accountByIban(storedIban) ?: return refuse() me.transactions(account.id()).between(from, to).list() ``` diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java index e19fdb2..c8e4a5c 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java @@ -68,9 +68,9 @@ public Instant tokenExpiry() { return client.tokenExpiry(token); } - /** As {@link MonetaClient#account(AccessToken, String)}. */ - public @Nullable Account account(String iban) { - return client.account(token, iban); + /** As {@link MonetaClient#accountByIban(AccessToken, String)}. */ + public @Nullable Account accountByIban(String iban) { + return client.accountByIban(token, iban); } private String onlyAccountId() { diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java index b0fb663..98c9af5 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java @@ -140,6 +140,45 @@ public List accounts(AccessToken token) { node -> Account.from(bind(node, AccountJson.class))); } + /** + * The account with this IBAN, compared without spaces and case, so {@code cz02 0600 …} as printed matches; an + * account without an IBAN never does. Reads {@link #accounts(AccessToken)} on every call and remembers nothing, + * so an account the token stopped seeing shows on the next one. + * + * @return null when the token does not see it: revoked for that account, or another holder's token + * @throws IllegalArgumentException when the IBAN is blank + * @throws IllegalStateException when more than one account has that IBAN, as one per currency could + * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes + * @throws CancellationException when the thread is interrupted; its interrupt flag stays set + */ + public @Nullable Account accountByIban(AccessToken token, String iban) { + var wanted = normalisedIban(requireNonNull(iban, "iban")); + if (wanted.isEmpty()) { + // Null would read as "revoked", and a caller may disconnect the merchant over its own empty value. + throw new IllegalArgumentException("iban is blank"); + } + var matches = accounts(token).stream() + .filter(account -> + account.iban() != null && normalisedIban(account.iban()).equals(wanted)) + .toList(); + if (matches.size() > 1) { + // A multi-currency account may list one entry per currency under one IBAN; picking one would read the + // wrong currency without an error. + throw new IllegalStateException("the token opens " + matches.size() + " accounts with that IBAN (" + + matches.stream().map(Account::id).toList() + "), so name one by its id"); + } + return matches.isEmpty() ? null : matches.getFirst(); + } + + /** Without any space, a no-break one copied from a PDF or a web page included; other characters are kept. */ + private static String normalisedIban(String iban) { + var kept = new StringBuilder(iban.length()); + iban.codePoints() + .filter(c -> !Character.isWhitespace(c) && !Character.isSpaceChar(c)) + .forEach(kept::appendCodePoint); + return kept.toString().toUpperCase(Locale.ROOT); + } + /** * The account's booked and available balance, as of the instant MONETA computed it. * @@ -199,45 +238,6 @@ public TransactionQuery transactions(AccessToken token, String accountId) { return new TransactionQuery(this, token, () -> accountId); } - /** - * The account with this IBAN, compared without spaces and case, so {@code cz02 0600 …} as printed matches; an - * account without an IBAN never does. Reads {@link #accounts(AccessToken)} on every call and remembers nothing, - * so an account the token stopped seeing shows on the next one. - * - * @return null when the token does not see it: revoked for that account, or another holder's token - * @throws IllegalArgumentException when the IBAN is blank - * @throws IllegalStateException when more than one account has that IBAN, as one per currency could - * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes - * @throws CancellationException when the thread is interrupted; its interrupt flag stays set - */ - public @Nullable Account account(AccessToken token, String iban) { - var wanted = normalisedIban(requireNonNull(iban, "iban")); - if (wanted.isEmpty()) { - // Null would read as "revoked", and a caller may disconnect the merchant over its own empty value. - throw new IllegalArgumentException("iban is blank"); - } - var matches = accounts(token).stream() - .filter(account -> - account.iban() != null && normalisedIban(account.iban()).equals(wanted)) - .toList(); - if (matches.size() > 1) { - // A multi-currency account may list one entry per currency under one IBAN; picking one would read the - // wrong currency without an error. - throw new IllegalStateException("the token opens " + matches.size() + " accounts with that IBAN (" - + matches.stream().map(Account::id).toList() + "), so name one by its id"); - } - return matches.isEmpty() ? null : matches.getFirst(); - } - - /** Without any space, a no-break one copied from a PDF or a web page included; other characters are kept. */ - private static String normalisedIban(String iban) { - var kept = new StringBuilder(iban.length()); - iban.codePoints() - .filter(c -> !Character.isWhitespace(c) && !Character.isSpaceChar(c)) - .forEach(kept::appendCodePoint); - return kept.toString().toUpperCase(Locale.ROOT); - } - /** The id of the only account the token opens; {@link IllegalStateException} unless there is exactly one. */ String onlyAccount(AccessToken token) { var accounts = accounts(token); diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index 34c1099..512c1cc 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -531,7 +531,7 @@ void aHolderAsksForItsOnlyAccountOnce() { void aHolderFindsAnAccountByItsIbanAsPrinted() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); - var found = client.with(TOKEN).account("cz02 0600 0000 0000 0000 1337"); + var found = client.with(TOKEN).accountByIban("cz02 0600 0000 0000 0000 1337"); assertEquals("acc-1", requireNonNull(found).id()); } @@ -540,14 +540,14 @@ void aHolderFindsAnAccountByItsIbanAsPrinted() { void anIbanTheTokenDoesNotSeeIsNullNotAnError() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); - assertNull(client.with(TOKEN).account("CZ4008000000004040404040")); + assertNull(client.with(TOKEN).accountByIban("CZ4008000000004040404040")); } @Test void aNoBreakSpaceCopiedFromAStatementMatchesToo() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); - var found = client.account(TOKEN, "CZ02 0600 0000\t0000 0000 1337"); + var found = client.accountByIban(TOKEN, "CZ02 0600 0000\t0000 0000 1337"); assertEquals("acc-1", requireNonNull(found).id()); } @@ -557,7 +557,7 @@ void aNoBreakSpaceCopiedFromAStatementMatchesToo() { void aBlankIbanIsTheCallersMistakeNotARevokedAccount(String blank) { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); - assertThrows(IllegalArgumentException.class, () -> client.account(TOKEN, blank)); + assertThrows(IllegalArgumentException.class, () -> client.accountByIban(TOKEN, blank)); } @Test @@ -569,8 +569,8 @@ void anIbanOnTwoAccountsIsRefusedRatherThanGuessed() { {"id": "acc-eur", "currency": "EUR", "identification": {"iban": "CZ0206000000000000001337"}} ], "pageNumber": 0, "pageCount": 1}"""); - var refused = - assertThrows(IllegalStateException.class, () -> client.account(TOKEN, "CZ0206000000000000001337")); + var refused = assertThrows( + IllegalStateException.class, () -> client.accountByIban(TOKEN, "CZ0206000000000000001337")); assertTrue(String.valueOf(refused.getMessage()).contains("acc-eur"), refused.getMessage()); } @@ -580,7 +580,7 @@ void theClientFindsAnAccountByIbanWithoutAHolder() { assertEquals( "acc-1", - requireNonNull(client.account(TOKEN, "CZ0206000000000000001337")) + requireNonNull(client.accountByIban(TOKEN, "CZ0206000000000000001337")) .id()); } @@ -589,8 +589,8 @@ void aHolderReadsTheAccountsAgainForEveryIban() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); var me = client.with(TOKEN); - me.account("CZ0206000000000000001337"); - me.account("CZ0206000000000000001337"); + me.accountByIban("CZ0206000000000000001337"); + me.accountByIban("CZ0206000000000000001337"); assertEquals( 2, From a5f30322511d6006b5c3d4746d08d2ff75eedcbd Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 19:41:05 +0100 Subject: [PATCH 07/10] be: keep only an iban's letters and digits, so zero-width spaces, dashes and dots match too --- .docs/usage.md | 2 +- .../src/main/java/cz/insuit/moneta/MonetaClient.java | 12 +++++++----- .../test/java/cz/insuit/moneta/MonetaClientTest.java | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index a17d061..27f7f37 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -43,7 +43,7 @@ find the one you stored by its IBAN, which is what the merchant gave you; MONETA nobody outside it. ```java -var account = me.accountByIban(storedIban); // spaces of any kind and case do not matter; dashes do +var account = me.accountByIban(storedIban); // only letters and digits count, in either case if (account == null) { return refuse(); // the token no longer sees it: revoked, or another token pasted } diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java index 98c9af5..f9a60c9 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java @@ -141,7 +141,8 @@ public List accounts(AccessToken token) { } /** - * The account with this IBAN, compared without spaces and case, so {@code cz02 0600 …} as printed matches; an + * The account with this IBAN, compared on its letters and digits regardless of case, so {@code cz02 0600 …} as + * printed matches; an * account without an IBAN never does. Reads {@link #accounts(AccessToken)} on every call and remembers nothing, * so an account the token stopped seeing shows on the next one. * @@ -170,12 +171,13 @@ public List accounts(AccessToken token) { return matches.isEmpty() ? null : matches.getFirst(); } - /** Without any space, a no-break one copied from a PDF or a web page included; other characters are kept. */ + /** + * ASCII letters and digits only, upper case. An IBAN holds nothing else, so every separator goes: spaces of any + * kind, zero-width ones a web page leaves behind, dashes and dots. + */ private static String normalisedIban(String iban) { var kept = new StringBuilder(iban.length()); - iban.codePoints() - .filter(c -> !Character.isWhitespace(c) && !Character.isSpaceChar(c)) - .forEach(kept::appendCodePoint); + iban.chars().filter(c -> c < 128 && Character.isLetterOrDigit(c)).forEach(c -> kept.append((char) c)); return kept.toString().toUpperCase(Locale.ROOT); } diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index 512c1cc..f9e53d8 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -544,10 +544,10 @@ void anIbanTheTokenDoesNotSeeIsNullNotAnError() { } @Test - void aNoBreakSpaceCopiedFromAStatementMatchesToo() { + void anySeparatorCopiedFromAStatementOrAPageMatches() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); - var found = client.accountByIban(TOKEN, "CZ02 0600 0000\t0000 0000 1337"); + var found = client.accountByIban(TOKEN, "\uFEFFCZ02\u00A00600\u202F0000\u200B0000-0000\t.1337"); assertEquals("acc-1", requireNonNull(found).id()); } From b90769d2756984bf08a5d14b1efe70973ea02ece Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 19:42:37 +0100 Subject: [PATCH 08/10] be: refuse an iban that fails its check digits, so a typo is not read as a revoked account --- .../java/cz/insuit/moneta/MonetaClient.java | 26 +++++++++++++++++-- .../cz/insuit/moneta/MonetaClientTest.java | 8 ++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java index f9a60c9..c73f66d 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaClient.java @@ -147,17 +147,20 @@ public List accounts(AccessToken token) { * so an account the token stopped seeing shows on the next one. * * @return null when the token does not see it: revoked for that account, or another holder's token - * @throws IllegalArgumentException when the IBAN is blank + * @throws IllegalArgumentException when the IBAN is blank or fails its check digits, a typo * @throws IllegalStateException when more than one account has that IBAN, as one per currency could * @throws MonetaException when MONETA refuses, fails or answers in an unexpected shape; see its subtypes * @throws CancellationException when the thread is interrupted; its interrupt flag stays set */ public @Nullable Account accountByIban(AccessToken token, String iban) { var wanted = normalisedIban(requireNonNull(iban, "iban")); + // Null would read as "revoked", and a caller may disconnect the merchant over its own typo. if (wanted.isEmpty()) { - // Null would read as "revoked", and a caller may disconnect the merchant over its own empty value. throw new IllegalArgumentException("iban is blank"); } + if (!ibanChecksOut(wanted)) { + throw new IllegalArgumentException("iban " + iban + " fails its check digits, so it is mistyped"); + } var matches = accounts(token).stream() .filter(account -> account.iban() != null && normalisedIban(account.iban()).equals(wanted)) @@ -171,6 +174,25 @@ public List accounts(AccessToken token) { return matches.isEmpty() ? null : matches.getFirst(); } + /** ISO 13616: two letters, two check digits, up to 30 more; moved to the end, the number is 1 modulo 97. */ + private static boolean ibanChecksOut(String normalised) { + if (normalised.length() < 5 + || normalised.length() > 34 + || !Character.isLetter(normalised.charAt(0)) + || !Character.isLetter(normalised.charAt(1)) + || !Character.isDigit(normalised.charAt(2)) + || !Character.isDigit(normalised.charAt(3))) { + return false; + } + var rearranged = normalised.substring(4) + normalised.substring(0, 4); + int remainder = 0; + for (int i = 0; i < rearranged.length(); i++) { + int value = Character.digit(rearranged.charAt(i), 36); + remainder = (remainder * (value < 10 ? 10 : 100) + value) % 97; + } + return remainder == 1; + } + /** * ASCII letters and digits only, upper case. An IBAN holds nothing else, so every separator goes: spaces of any * kind, zero-width ones a web page leaves behind, dashes and dots. diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index f9e53d8..86b78ca 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -574,6 +574,14 @@ void anIbanOnTwoAccountsIsRefusedRatherThanGuessed() { assertTrue(String.valueOf(refused.getMessage()).contains("acc-eur"), refused.getMessage()); } + @ParameterizedTest + @ValueSource( + strings = {"CZ0206000000000000001373", "CZ0306000000000000001337", "CZ02", "0206000000000000001337"}) + void aMistypedIbanIsRefusedRatherThanReadAsRevoked(String typo) { + assertThrows(IllegalArgumentException.class, () -> client.accountByIban(TOKEN, typo)); + assertTrue(requests.isEmpty()); + } + @Test void theClientFindsAnAccountByIbanWithoutAHolder() { answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); From 915b5ed6c61fd2cd3cb421447cb72dc4eb3c3738 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 19:43:20 +0100 Subject: [PATCH 09/10] be: prove a refused iban sends no request, and write the no-break space as an escape --- .../test/java/cz/insuit/moneta/MonetaClientTest.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index 86b78ca..e855bc5 100644 --- a/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java +++ b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java @@ -1,6 +1,5 @@ package cz.insuit.moneta; -import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -533,7 +532,7 @@ void aHolderFindsAnAccountByItsIbanAsPrinted() { var found = client.with(TOKEN).accountByIban("cz02 0600 0000 0000 0000 1337"); - assertEquals("acc-1", requireNonNull(found).id()); + assertEquals("acc-1", Objects.requireNonNull(found).id()); } @Test @@ -549,15 +548,14 @@ void anySeparatorCopiedFromAStatementOrAPageMatches() { var found = client.accountByIban(TOKEN, "\uFEFFCZ02\u00A00600\u202F0000\u200B0000-0000\t.1337"); - assertEquals("acc-1", requireNonNull(found).id()); + assertEquals("acc-1", Objects.requireNonNull(found).id()); } @ParameterizedTest - @ValueSource(strings = {"", " ", " "}) + @ValueSource(strings = {"", " ", "\u00A0", "-"}) void aBlankIbanIsTheCallersMistakeNotARevokedAccount(String blank) { - answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN); - assertThrows(IllegalArgumentException.class, () -> client.accountByIban(TOKEN, blank)); + assertTrue(requests.isEmpty()); } @Test @@ -588,7 +586,7 @@ void theClientFindsAnAccountByIbanWithoutAHolder() { assertEquals( "acc-1", - requireNonNull(client.accountByIban(TOKEN, "CZ0206000000000000001337")) + Objects.requireNonNull(client.accountByIban(TOKEN, "CZ0206000000000000001337")) .id()); } From 217d11e16ef7b9c119271b4cb9939c585705c11a Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Thu, 12 Nov 2026 19:43:39 +0100 Subject: [PATCH 10/10] docs: fold the iban lookup into the section on several accounts, so one topic has one place --- .docs/usage.md | 49 +++++++++++++++++++------------------------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index 27f7f37..39bc3e1 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -36,31 +36,6 @@ me.tokenExpiry() // when the token stops working: ask the With the Spring Boot starter, `moneta.token` gives you the `AccessToken` bean. -## A token with several accounts - -`me.transactions()` and `me.balance()` read the only account a token opens and refuse a token with more. Then -find the one you stored by its IBAN, which is what the merchant gave you; MONETA's own account id is known to -nobody outside it. - -```java -var account = me.accountByIban(storedIban); // only letters and digits count, in either case -if (account == null) { - return refuse(); // the token no longer sees it: revoked, or another token pasted -} -me.transactions(account.id()).between(from, to).list(); -``` - -
Kotlin - -```kotlin -val account = me.accountByIban(storedIban) ?: return refuse() -me.transactions(account.id()).between(from, to).list() -``` - -
- -Each call reads the accounts again, so an account revoked for the token shows on the next one. - ## Filtering | Step | Keeps | @@ -77,24 +52,38 @@ Each call reads the accounts again, so an account revoked for the token shows on ## More than one account `transactions()` and `balance()` read the only account the token opens, and throw `IllegalStateException` when -there are more. Name the account then: +there are more. Name the account then. To read the one you stored, find it by its IBAN, which is what the +merchant gave you; MONETA's own account id is known to nobody outside it: ```java -for (var account : me.accounts()) { - me.transactions(account.id()).between(from, to).cards().list(); +var account = me.accountByIban(storedIban); // only letters and digits count, in either case +if (account == null) { + return refuse(); // the token no longer sees it: revoked, or another token pasted +} +me.transactions(account.id()).between(from, to).list(); + +for (var other : me.accounts()) { // or every account the token opens + me.transactions(other.id()).between(from, to).cards().list(); } ```
Kotlin ```kotlin -for (account in me.accounts()) { - me.transactions(account.id).between(from, to).cards().list() +val account = me.accountByIban(storedIban) ?: return refuse() +me.transactions(account.id).between(from, to).list() + +for (other in me.accounts()) { + me.transactions(other.id).between(from, to).cards().list() } ```
+`accountByIban` reads the accounts on every call, so an account revoked for the token shows on the next one. A +blank IBAN, or one failing its check digits, is an `IllegalArgumentException`: a typo, not a revoked account. +Two accounts with one IBAN, one per currency, are an `IllegalStateException` naming both ids. + Several account holders, each with their own token, share one client: ```java