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
1 change: 1 addition & 0 deletions .docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .docs/moneta-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 2 additions & 4 deletions .docs/plan/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<details><summary>Kotlin</summary>
Expand All @@ -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
```

</details>
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ | ⬜ |
Expand Down
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +63,11 @@ public List<Account> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
landsman marked this conversation as resolved.
Resolved
TokenIntrospectionJson {
requireNonNull(expiration, "expiration");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>();
var client = MonetaClient.builder()
.baseUri(base)
Expand All @@ -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());
}

Expand Down
4 changes: 3 additions & 1 deletion modules/moneta-api/src/test/resources/moneta/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"expiration": "2026-03-06T11:38:09+01:00"}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>Usage: {@code NativeCaller <dir of modules/moneta-api/src/test/resources/moneta>}
*/
Expand All @@ -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() + "/");
Expand All @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion tools/peek/src/main/java/peek/Peek.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
}
Expand Down
Loading