diff --git a/.docs/usage.md b/.docs/usage.md
index 27f76e7..39bc3e1 100644
--- a/.docs/usage.md
+++ b/.docs/usage.md
@@ -52,24 +52,38 @@ With the Spring Boot starter, `moneta.token` gives you the `AccessToken` bean.
## 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
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..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,6 +68,11 @@ public Instant tokenExpiry() {
return client.tokenExpiry(token);
}
+ /** As {@link MonetaClient#accountByIban(AccessToken, String)}. */
+ public @Nullable Account accountByIban(String iban) {
+ return client.accountByIban(token, iban);
+ }
+
private String onlyAccountId() {
var id = onlyAccountId;
if (id == null) {
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..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
@@ -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;
@@ -139,6 +140,69 @@ public List accounts(AccessToken token) {
node -> Account.from(bind(node, AccountJson.class)));
}
+ /**
+ * 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.
+ *
+ * @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 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()) {
+ 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))
+ .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();
+ }
+
+ /** 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.
+ */
+ private static String normalisedIban(String iban) {
+ var kept = new StringBuilder(iban.length());
+ iban.chars().filter(c -> c < 128 && Character.isLetterOrDigit(c)).forEach(c -> kept.append((char) c));
+ return kept.toString().toUpperCase(Locale.ROOT);
+ }
+
/**
* The account's booked and available balance, as of the instant MONETA computed it.
*
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..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
@@ -71,6 +71,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 +526,85 @@ void aHolderAsksForItsOnlyAccountOnce() {
.count());
}
+ @Test
+ void aHolderFindsAnAccountByItsIbanAsPrinted() {
+ answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN);
+
+ var found = client.with(TOKEN).accountByIban("cz02 0600 0000 0000 0000 1337");
+
+ assertEquals("acc-1", Objects.requireNonNull(found).id());
+ }
+
+ @Test
+ void anIbanTheTokenDoesNotSeeIsNullNotAnError() {
+ answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN);
+
+ assertNull(client.with(TOKEN).accountByIban("CZ4008000000004040404040"));
+ }
+
+ @Test
+ void anySeparatorCopiedFromAStatementOrAPageMatches() {
+ answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN);
+
+ var found = client.accountByIban(TOKEN, "\uFEFFCZ02\u00A00600\u202F0000\u200B0000-0000\t.1337");
+
+ assertEquals("acc-1", Objects.requireNonNull(found).id());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", " ", "\u00A0", "-"})
+ void aBlankIbanIsTheCallersMistakeNotARevokedAccount(String blank) {
+ assertThrows(IllegalArgumentException.class, () -> client.accountByIban(TOKEN, blank));
+ assertTrue(requests.isEmpty());
+ }
+
+ @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.accountByIban(TOKEN, "CZ0206000000000000001337"));
+ 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);
+
+ assertEquals(
+ "acc-1",
+ Objects.requireNonNull(client.accountByIban(TOKEN, "CZ0206000000000000001337"))
+ .id());
+ }
+
+ @Test
+ void aHolderReadsTheAccountsAgainForEveryIban() {
+ answer(ACCOUNTS, 200, TWO_ACCOUNTS_ONE_WITHOUT_IBAN);
+ var me = client.with(TOKEN);
+
+ me.accountByIban("CZ0206000000000000001337");
+ me.accountByIban("CZ0206000000000000001337");
+
+ assertEquals(
+ 2,
+ requests.stream()
+ .filter(r -> r.getRequestURI().getPath().equals(ACCOUNTS))
+ .count());
+ }
+
@Test
void aQueryWithoutAnAccountRefusesATokenWithTwo() {
answer(ACCOUNTS, 200, """