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
24 changes: 19 additions & 5 deletions .docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
```

<details><summary>Kotlin</summary>

```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()
}
```

</details>

`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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -139,6 +140,69 @@ public List<Account> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": []}""";
Expand Down Expand Up @@ -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, """
Expand Down
Loading