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, """