diff --git a/.docs/development.md b/.docs/development.md index e616be3..7b6cc3c 100644 --- a/.docs/development.md +++ b/.docs/development.md @@ -27,6 +27,7 @@ before the client maps it. `tools/peek` is internal and never published. make peek FROM=2026-11-01 TO=2026-11-07 # a range; SHAPE=1 shows masked text as x and 9 instead of a length make peek-accounts make peek-balance + make peek-token # when the token expires make record-fixture N=3 NAME=booked-card FROM=2026-11-01 TO=2026-11-07 # movement 3 of that make peek The token is read from 1Password for each run: `op run` resolves `MONETA_OP`, a secret reference that defaults to diff --git a/.docs/moneta-api.md b/.docs/moneta-api.md index d875743..1eb2249 100644 --- a/.docs/moneta-api.md +++ b/.docs/moneta-api.md @@ -49,7 +49,8 @@ and a portal account: [access](access.md). credentials and scope `vip_sandbox`, valid 1200 seconds. It answers fixed test data and validates almost no parameter, so it shows the shapes, not the errors. - **A token's expiry** is `GET /api/v1/vip/token/introspect`: `{"expiration": "2026-03-06T11:38:09+01:00"}` for - the token sent, a 403 for one that is not a MONETA API token. Not tried live yet. + the token sent, a 403 for one that is not a MONETA API token. Live on 2026-11-11 it answered + `2027-02-08T17:25:00Z` for a merchant token issued in November 2026. - **An application without an API plan in the portal gets a 401** in the sandbox, per the payment specifications. - **The partner headers** `identification_number`, `business_name`, `application_name` are optional, per the specification; every live call was made without them (2026-11-06). diff --git a/.docs/plan/roadmap.md b/.docs/plan/roadmap.md index 84d811e..a060bf7 100644 --- a/.docs/plan/roadmap.md +++ b/.docs/plan/roadmap.md @@ -14,6 +14,7 @@ behaves, as far as it is known, is [moneta-api.md](../moneta-api.md). - **The developer portal registration** (MONETA API – TPP) was approved. It gives the specification and a sandbox; production stays the merchant's token, as without a PSD2 licence the portal offers no more ([access](../access.md)). +- **A token's expiry** is `tokenExpiry()`, checked live on 2026-11-11. - **[examples/cafe](../../examples/cafe/README.md)** issues bills as QR payments and finds them paid, live. ## Still unknown @@ -24,7 +25,7 @@ Not in MONETA's specifications, read 2026-11-11: quotas" in the organisation's settings, not looked at yet. Until known, a 429 fails the whole read. If a normal read can hit them, the client retries the page it stopped on, only after a short `Retry-After`, a few times, and that can be turned off. -- **A merchant token's rights**: whether it can be read-only. Its expiry is not unknown any more, see Next. +- **A merchant token's rights**: whether it can be read-only. Its expiry is `tokenExpiry()`. - **Reversals**: the specification calls `reversalIndicator: true` a cancellation; not seen live. - **`business_name` with diacritics**: the client accepts ASCII only for now. - **Webhooks**, as an alternative to polling. @@ -36,9 +37,6 @@ credentials token; a merchant token's expiry is readable per token. ## Next -- **A token's expiry**: `GET /api/v1/vip/token/introspect` answers `{"expiration": "2026-03-06T11:38:09+01:00"}` - for the token sent, and a 403 for one that is not a MONETA API token (`VipTokenIntrospect_v1` 1.0.0). As - `client.tokenExpiry(token)`, an app warns the merchant before the token stops working, not after a 401. - **Maven Central**, once the portal documentation confirms the shapes: signing, a release process on JReleaser ([#14](https://github.com/landsman/moneta-api-java/issues/14)). - **A cold review** of the starter, the modules layout and the booked-movement changes diff --git a/.docs/usage.md b/.docs/usage.md index af0360b..27f76e7 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -12,6 +12,7 @@ november.transfers().outgoing().list(); // transfers that went out november.cards().list(); // paid by the account's card me.balance(); // booked and available, with the instant MONETA computed it +me.tokenExpiry(); // when the token stops working: ask the merchant for a new one before it does ```
Kotlin @@ -28,6 +29,7 @@ november.transfers().outgoing().list() // transfers that went out november.cards().list() // paid by the account's card me.balance() // booked and available, with the instant MONETA computed it +me.tokenExpiry() // when the token stops working: ask the merchant for a new one before it does ```
diff --git a/Makefile b/Makefile index d671d14..1561c1c 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ publish-local: ## install every jar into ~/.m2, to try them in another project w MONETA_OP ?= op://dev-homelab/moneta money bank - api token/credential PEEK = ./gradlew -q :tools:peek:installDist && MONETA_TOKEN="$(MONETA_OP)" op run -- tools/peek/build/install/peek/bin/peek -.PHONY: peek peek-accounts peek-balance record-fixture +.PHONY: peek peek-accounts peek-balance peek-token record-fixture peek: ## print the account's movements as MONETA sends them, people masked; FROM=2026-11-01 TO=2026-11-07, SHAPE=1 @[ -n "$(FROM)" ] || [ -z "$(TO)" ] || { echo 'FROM and TO go together; neither is the last week' >&2; exit 1; } $(PEEK) transactions $(FROM) $(TO) $(if $(SHAPE),--shape) @@ -50,6 +50,9 @@ peek-accounts: ## print the accounts the token opens peek-balance: ## print each account's booked and available balance $(PEEK) balance +peek-token: ## print when the token expires + $(PEEK) token + record-fixture: ## save movement N of make peek FROM= TO=, the same dates, as …/moneta/live/NAME.json, people replaced @[ -n "$(N)" ] && [ -n "$(NAME)" ] && [ -n "$(FROM)" ] && [ -n "$(TO)" ] || { echo 'make record-fixture N=3 NAME=booked-card FROM=… TO=…, as make peek FROM=… TO=… numbered it' >&2; exit 1; } $(PEEK) record "$(N)" "$(NAME)" $(FROM) $(TO) diff --git a/README.md b/README.md index eba1372..368dbea 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ client implements: | Transactions, pending and booked | ✅ | ✅ | | Card transactions | ✅ | in the transaction list, `method()`; not their own endpoint | | Balances | ✅ | ✅ | +| When a token expires | ✅ | ✅ | | Pending card transactions | ✅ | ⬜ | | Electronic statements | ✅ | ⬜ | | Overview of payment orders | ✅ | ⬜ | diff --git a/llms.txt b/llms.txt index 7854eec..861537c 100644 --- a/llms.txt +++ b/llms.txt @@ -22,6 +22,7 @@ Mistakes to avoid: - `entryReference` changes when a pending movement is booked: deduplicating by it counts a payment twice. - Movements have dates only, no time of day. +- A merchant's token expires: check `tokenExpiry()` on a schedule and ask for a new one before it does. - `amount()` is signed: negative is money out. - `Transaction.Method` gains constants: keep a `default` branch. - A read is all or nothing across pages; read long ranges month by month. 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 cadd4d7..0c7aa71 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 @@ -2,6 +2,7 @@ import static java.util.Objects.requireNonNull; +import java.time.Instant; import java.util.List; import java.util.concurrent.CancellationException; import org.jspecify.annotations.Nullable; @@ -62,6 +63,11 @@ public List accounts() { return client.accounts(token); } + /** As {@link MonetaClient#tokenExpiry(AccessToken)}. */ + public Instant tokenExpiry() { + return client.tokenExpiry(token); + } + 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 71dedcc..9ad9e73 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 @@ -10,6 +10,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -151,6 +152,21 @@ public Balance balance(AccessToken token, String accountId) { return Balance.from(what, get(token, what, path, BalancesJson.class)); } + /** + * When the token stops working, so the merchant can be asked for a new one before it does. A sandbox token + * expires after 20 minutes; a merchant's from Internet Banka lives longer, how long is up to MONETA. + * + * @throws MonetaException.InvalidToken when the token has expired, or is not a MONETA API token (a 403) + * @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 Instant tokenExpiry(AccessToken token) { + var path = MonetaPaths.TOKEN_INTROSPECT; + return get(token, "GET /" + path, path, TokenIntrospectionJson.class) + .expiration() + .toInstant(); + } + /** * The money that came in: {@link #transactions(AccessToken, String, LocalDate, LocalDate)} with only {@link Transaction.Direction#CREDIT}, pending ones * included. MONETA has no such filter of its own that is known, so all movements are read and the debits diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaPaths.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaPaths.java index 6c670fc..21e6c41 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaPaths.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/MonetaPaths.java @@ -10,6 +10,8 @@ final class MonetaPaths { static final String ACCOUNTS = AISP + "accounts"; + static final String TOKEN_INTROSPECT = "api/v1/vip/token/introspect"; + /** What {@link #template} answers for a path the client does not call. */ static final String UNKNOWN = "UNKNOWN"; @@ -22,8 +24,8 @@ final class MonetaPaths { new Template( "/" + ACCOUNTS + "/{accountId}/transactions", Pattern.compile(".*/" + ACCOUNTS + "/[^/]+/transactions")), - new Template( - "/" + ACCOUNTS + "/{accountId}/balance", Pattern.compile(".*/" + ACCOUNTS + "/[^/]+/balance"))); + new Template("/" + ACCOUNTS + "/{accountId}/balance", Pattern.compile(".*/" + ACCOUNTS + "/[^/]+/balance")), + new Template("/" + TOKEN_INTROSPECT, Pattern.compile(".*/" + TOKEN_INTROSPECT))); static String transactions(String accountId) { return account(accountId) + "/transactions"; diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/TokenIntrospectionJson.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/TokenIntrospectionJson.java new file mode 100644 index 0000000..5dcc2b0 --- /dev/null +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/TokenIntrospectionJson.java @@ -0,0 +1,12 @@ +package cz.insuit.moneta; + +import static java.util.Objects.requireNonNull; + +import java.time.OffsetDateTime; + +/** {@code GET /api/v1/vip/token/introspect}: {@code {"expiration": "2026-03-06T11:38:09+01:00"}}. */ +record TokenIntrospectionJson(OffsetDateTime expiration) { + TokenIntrospectionJson { + requireNonNull(expiration, "expiration"); + } +} 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 8f14607..2c4699e 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 @@ -62,6 +62,7 @@ class MonetaClientTest { private static final String ACCOUNTS = "/api/v4/vip/aisp/my/accounts"; private static final String TRANSACTIONS = ACCOUNTS + "/acc-1/transactions"; private static final String BALANCE = ACCOUNTS + "/acc-1/balance"; + private static final String TOKEN_INTROSPECT = "/api/v1/vip/token/introspect"; private static final LocalDate FROM = LocalDate.of(2026, 8, 1); private static final LocalDate TO = LocalDate.of(2026, 8, 31); private static final Currency CZK = Currency.getInstance("CZK"); @@ -345,6 +346,31 @@ void anOverdrawnBalanceIsBelowZero() { assertEquals(new Money(new BigDecimal("87.50"), CZK), balance.available()); } + @Test + void readsWhenTheTokenExpires() { + // The example from MONETA's specification, VipTokenIntrospect_v1. + answer(TOKEN_INTROSPECT, 200, """ + {"expiration": "2026-03-06T11:38:09+01:00"}"""); + + assertEquals( + Instant.parse("2026-03-06T10:38:09Z"), client.with(TOKEN).tokenExpiry()); + } + + @Test + void aTokenAnswerWithoutAnExpiryIsRefused() { + answer(TOKEN_INTROSPECT, 200, "{}"); + + assertThrows(MonetaException.InvalidResponse.class, () -> client.tokenExpiry(TOKEN)); + } + + @Test + void aTokenThatIsNotMonetasIsInvalidToken() { + answer(TOKEN_INTROSPECT, 403, """ + {"errors": [{"error": "FORBIDDEN", "message": "This is not a Moneta API token."}]}"""); + + assertThrows(MonetaException.InvalidToken.class, () -> client.tokenExpiry(TOKEN)); + } + @Test void aBalanceDatedWithoutATimeStartsThatDayInPrague() { // The specification allows "date or date and time"; live has always sent the time so far. @@ -879,6 +905,8 @@ void namesEachCallByItsTemplateWithoutTheAccountIdOrTheQuery() { answer(ACCOUNTS, 200, fixture("accounts.json")); answer(BALANCE, 200, fixture("live/balance.json")); answer(TRANSACTIONS, 200, NO_TRANSACTIONS); + answer(TOKEN_INTROSPECT, 200, """ + {"expiration": "2026-03-06T11:38:09+01:00"}"""); var templates = new ArrayList(); var client = MonetaClient.builder() .baseUri(base) @@ -889,9 +917,14 @@ void namesEachCallByItsTemplateWithoutTheAccountIdOrTheQuery() { client.accounts(TOKEN); client.balance(TOKEN, "acc-1"); client.transactions(TOKEN, "acc-1", FROM, TO); + client.tokenExpiry(TOKEN); assertEquals( - List.of(ACCOUNTS, ACCOUNTS + "/{accountId}/balance", ACCOUNTS + "/{accountId}/transactions"), + List.of( + ACCOUNTS, + ACCOUNTS + "/{accountId}/balance", + ACCOUNTS + "/{accountId}/transactions", + TOKEN_INTROSPECT), templates.stream().distinct().toList()); } diff --git a/modules/moneta-api/src/test/resources/moneta/README.md b/modules/moneta-api/src/test/resources/moneta/README.md index 10a2cc4..fbeee73 100644 --- a/modules/moneta-api/src/test/resources/moneta/README.md +++ b/modules/moneta-api/src/test/resources/moneta/README.md @@ -1,9 +1,11 @@ # Test responses -Two kinds, told apart by the folder: +Three kinds: - **`live/`** — recorded from the live API, then anonymised. The shape is MONETA's own; every identifying value is replaced as below. +- **`token-introspect.json`**: the example from MONETA's specification (VipTokenIntrospect_v1), kept as it is: + MONETA's own shape, and it names nobody. - **The rest** — written by hand in the shape the only public client (a PHP package) assumes, before anything was recorded. Replace each with a recorded one as the live API shows it. diff --git a/modules/moneta-api/src/test/resources/moneta/token-introspect.json b/modules/moneta-api/src/test/resources/moneta/token-introspect.json new file mode 100644 index 0000000..f654b7c --- /dev/null +++ b/modules/moneta-api/src/test/resources/moneta/token-introspect.json @@ -0,0 +1 @@ +{"expiration": "2026-03-06T11:38:09+01:00"} diff --git a/testing/native/src/main/java/example/nativeimage/NativeCaller.java b/testing/native/src/main/java/example/nativeimage/NativeCaller.java index 2268bc3..818be43 100644 --- a/testing/native/src/main/java/example/nativeimage/NativeCaller.java +++ b/testing/native/src/main/java/example/nativeimage/NativeCaller.java @@ -12,9 +12,9 @@ import java.time.LocalDate; /** - * Reads accounts, a balance and transactions from a fake MONETA serving the client's test fixtures, and prints - * what the client returned. bin/native-test.sh runs it on the JVM and as a native image and compares the two: a - * record the image cannot bind fails the call, and a component it cannot see prints as null. + * Reads accounts, a balance, transactions and the token's expiry from a fake MONETA serving the client's test + * fixtures, and prints what the client returned. bin/native-test.sh runs it on the JVM and as a native image and + * compares the two: a record the image cannot bind fails the call, and a component it cannot see prints as null. * *

Usage: {@code NativeCaller

} */ @@ -28,6 +28,8 @@ public static void main(String[] args) throws IOException { serve(server, ACCOUNTS, fixtures.resolve("accounts.json")); serve(server, ACCOUNTS + "/acc-1/balance", fixtures.resolve("live/balance.json")); serve(server, ACCOUNTS + "/acc-1/transactions", fixtures.resolve("live/booked.json")); + // The only record whose java.time component Jackson binds itself rather than a String parsed after. + serve(server, "/api/v1/vip/token/introspect", fixtures.resolve("token-introspect.json")); server.start(); var address = server.getAddress(); var base = URI.create("http://" + address.getHostString() + ":" + address.getPort() + "/"); @@ -37,6 +39,7 @@ public static void main(String[] args) throws IOException { System.out.println(moneta.balance(token, "acc-1")); moneta.transactions(token, "acc-1", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)) .forEach(System.out::println); + System.out.println(moneta.tokenExpiry(token)); } finally { server.stop(0); } diff --git a/tools/peek/src/main/java/peek/Peek.java b/tools/peek/src/main/java/peek/Peek.java index 49d9985..33f712b 100644 --- a/tools/peek/src/main/java/peek/Peek.java +++ b/tools/peek/src/main/java/peek/Peek.java @@ -57,8 +57,9 @@ public static void main(String[] args) throws IOException { } } } + case "token" -> System.out.println("expires " + moneta.tokenExpiry(access)); case "record" -> record(moneta, access, arguments); - default -> fail("unknown command " + command + "; accounts, balance, transactions or record"); + default -> fail("unknown command " + command + "; accounts, balance, token, transactions or record"); } } }