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
22 changes: 19 additions & 3 deletions .docs/access.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,25 @@ After the form comes an e-mail to confirm, then **approval by hand**: apiportal@
"is currently under review", with no time frame. One account per organisation; a colleague asks its admin to be
added rather than signing up again.

Approval opens the documentation, a sandbox where the partner issues its own tokens, and temporary tokens from
an authentication endpoint ([developer page](https://www.moneta.cz/zivnostnici-a-firmy/api-pro-vyvojare)).
Until then the production API with a merchant's token is the only way to try the client; `make dev` does that.
Approval opens the documentation and a sandbox, and nothing more without a PSD2 licence: the portal's Production
plan "requires a valid PSD2 license", and non-PSD2 partners get "Sandbox only". **Production is always the
merchant's token**; the portal's credentials reach only the sandbox. `make dev` runs against production.

For the sandbox, from the portal's guide (2026-11-11):

1. Ask MONETA's integrations admin for the Sandbox API plan on the service, e.g. `VipAISPPayments_v4`.
2. In the portal application, Key Overview: generate a key (the client id), set the scope to `vip_sandbox` and
the type to Confidential.
3. Get a token, valid 1200 seconds:

curl https://api.moneta.cz/auth/oauth/v2/token \
--data-urlencode client_id=… --data-urlencode client_secret=… \
--data-urlencode grant_type=client_credentials --data-urlencode scope=vip_sandbox

4. Send it as `Authorization: Bearer`, to the same host as production.

The sandbox answers fixed test data and validates almost nothing, so errors are tried against a real account.
The client has no sandbox mode: a sandbox token is passed as any `AccessToken`, and renewed by the caller.

## Contacts

Expand Down
6 changes: 6 additions & 0 deletions .docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ parties it has no rule for, or any value `make peek` would mask that is not a fi
peek` numbered with, since a range ending today renumbers when a payment arrives. It never overwrites a fixture. Read the file whole before committing it: the
repository is public.

## MONETA's specification

The developer portal gives each service's OpenAPI file on its Spec tab (`VipAISPPayments_v4.json` for accounts,
balances, transactions and statements). Put downloads in [spec/](../spec), which git ignores: the portal's terms
do not say yet that they may be republished, and this repository is public.

## Releasing

A release is made by hand, and only a tag makes one: merging to `main` publishes nothing. A tag `vX.Y.Z` on
Expand Down
43 changes: 31 additions & 12 deletions .docs/moneta-api.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# How MONETA API behaves

What a client of MONETA API has to know, whatever library it uses. MONETA publishes its specification only to
organisations registered in its [developer portal](https://apiportal.moneta.cz/pages/moneta/home.html), so
everything here was observed: on a live business account on 2026-11-06 and 2026-11-07, with instant payments
in and out, read pending and then booked. A claim carries its date where it could change. What is still
What a client of MONETA API has to know, whatever library it uses. Observed on a live business account from
2026-11-06, with instant payments in and out, read pending and then booked, and checked on 2026-11-11 against
MONETA's specification, `VipAISPPayments_v4` 1.0.1 (OpenAPI 3.0.3, updated 2026-03-31), which only organisations
registered in its [developer portal](https://apiportal.moneta.cz/pages/moneta/home.html) can download. A claim
carries its date where it could change. What is still
unknown, and what this library does next, is the working document [plan/roadmap.md](plan/roadmap.md).

## Watch out for

1. **A movement's `entryReference` changes when it is booked.** Deduplicate by it and every payment counts
twice, once pending and once booked.
2. **No movement carries a time of day**, only dates. "Payments in the last hour" cannot be asked of MONETA; a
2. **No movement carries a time of day**, only dates; the specification has none either. "Payments in the last hour" cannot be asked of MONETA; a
poller records when it first saw each movement, which is as exact as its interval.
3. **The date of a payment is a different field before and after booking**: `enteredDate` while pending,
`valueDate` once booked. `bookingDate` can be a later business day than the payment.
Expand All @@ -21,6 +22,8 @@ unknown, and what this library does next, is the working document [plan/roadmap.
6. **An unknown query parameter is ignored without an error**, so a misspelt filter returns the whole account.
7. **A page past the last is a 404**, not an empty page.
8. **A nonsense date is a 500**, which a retrying client mistakes for an outage.
9. **A page can hold more than `size`**: the specification caps `size` at 100 and adds pending (blocked)
movements on top.

## MONETA API and PSD2

Expand All @@ -40,9 +43,13 @@ and a portal account: [access](access.md).

- **Host and paths:** `https://api.moneta.cz/api/v4/vip/aisp/my/accounts` and
`…/accounts/{id}/transactions`; the account id is its 10-digit number.
- **Authentication:** `Authorization: Bearer <token>`, the token the merchant issued in Internet Banka.
- **The partner headers** `identification_number`, `business_name`, `application_name` are not required: every
call was made without them (2026-11-06).
- **Authentication:** `Authorization: Bearer <token>`, the token the merchant issued in Internet Banka. That
token is the only way to production for a partner without a PSD2 licence ([access](access.md)).
- **The sandbox is the same host**, chosen by the token: one from `POST /auth/oauth/v2/token` with client
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.
- **The partner headers** `identification_number`, `business_name`, `application_name` are optional, per the
specification; every live call was made without them (2026-11-06).
- **A response carries `X-Request-Id`**, what MONETA's support asks for about one call, and `Is-Sandbox: false`
on production.

Expand All @@ -52,7 +59,10 @@ and a portal account: [access](access.md).
default is 10.
- **`toDate` is inclusive**: `fromDate` = `toDate` = a day returned that day's pending movements (2026-11-06). No
limit on a range's length was met, back to 2020.
- **A pending movement is filtered by `enteredDate`** (2026-11-06).
- **A pending movement is filtered by `enteredDate`** (2026-11-06). The specification says the range filters on
`valueDate`, which a pending movement does not have.
- **`sort` and `order`** exist: `sort=date` (`bookingDate`, or `enteredDate` for a card hold) or `sort=amount`,
`order=ASC` or `DESC`. Not used by this client.
- **A movement booked for a later day comes back from every range that starts on or before its `bookingDate`**,
whatever `toDate` says. On Saturday 2026-11-07 the account held ten movements valued 6 and 7 November and booked
for Monday 9 November. Each of these ranges returned all ten: a single day from 3 to 9 November,
Expand All @@ -72,6 +82,10 @@ and a portal account: [access](access.md).
| `toDate` before `fromDate` | 400 `{"errors":[{"error":"DT01","message":"Neplatné datum."}]}` |
| `fromDate=garbage` | 500 `cbl.process.request.exception`, "Zkuste zadání vašeho požadavku opakovat později." |

Every error body is `{"errors": [{"error", "message", "parameters", "scope"}]}`, and `message` is "not for direct
output to the user". **The specification documents a 429** for too many requests, with no limit and no
`Retry-After`; none has been met yet.

## A balance

`GET …/accounts/{id}/balance` (not `balances`, which is a 400 `INVALID_REQUEST`) answers two entries, read on
Expand All @@ -80,10 +94,11 @@ replaced):

- **`type.codeOrProprietary.code`** is `CLBD` for the booked balance and `CLAV` for the available one.
- **The amount is unsigned beside `creditDebitIndicator`**, as a movement's.
- **`date` is `{"dateTime": "2026-11-07T15:32:12.391Z"}`**, an instant in UTC: the `{"date": …}` wrapper of a
movement has a date-time choice, and the balance uses it.
- **`date` is `{"dateTime": "2026-11-07T15:32:12.391Z"}`**, an instant in UTC. The specification allows a bare
date there too; this client reads one as the start of that day in Prague.
- **`creditLine`** is `{"amount": {"value": 0}, "included": true}` on an account without one, its amount
without a currency.
without a currency. The specification does not list it, nor a movement's `bankTransactionCode`; both come
live (2026-11-11).

## A movement

Expand Down Expand Up @@ -169,6 +184,10 @@ Once booked, the same payment changed this much:
- **No time of day exists anywhere**, pending or booked (checked again on booked movements, 2026-11-07): not in
the body, the headers or the `entryReference`, which grows with
time but decodes to no timestamp. Internet Banka's export has none either.
- **`reversalIndicator: true` marks a cancellation**, per the specification; none has been seen live.
- **`clearingSystemReference` is one of 25 values** in the specification: transfers, foreign payments, standing
orders, card payments, SIPO, direct debits, interest, fees, loans and deposits, each `BLOCKED_` variant for a
pending one.
- **An account has no `nameI18N` unless one is set**; `productI18N` names the product ("Běžný účet").

## Deduplicating payments
Expand Down
40 changes: 25 additions & 15 deletions .docs/plan/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,22 @@ behaves, as far as it is known, is [moneta-api.md](../moneta-api.md).
`incoming()`, and tells a card payment from a transfer with `method()`. Checked against a live business account
on 2026-11-06 and 2026-11-07, a card payment pending on 2026-11-07 and booked on 2026-11-10.
- **Tested** on Java 21 and 25, Spring Boot 3.5.12, 3.5.16, 4.0.0 and 4.1.2, a Kotlin caller, and Jackson 3.0.4.
- **The developer portal registration** (MONETA API – TPP) waits for MONETA's manual approval; until then
there is no sandbox and no documentation, only the production API with a merchant's token.
- **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)).
- **[examples/cafe](../../examples/cafe/README.md)** issues bills as QR payments and finds them paid, live.

## Still unknown

Mostly behind the portal, so expected once the registration is approved
([#11](https://github.com/landsman/moneta-api-java/issues/11)):

- **A time of day for a movement**, if any field or endpoint has one
([#9](https://github.com/landsman/moneta-api-java/issues/9)).
- **Rate limits**: per token, application or partner; the window; whether a 429 carries `Retry-After`. 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.
- **Token rights and lifetime**: whether a token can be read-only, how long it lives.
- **The sandbox's host** and how its tokens are issued.
- **Reversals**: what a movement with `reversalIndicator: true` looks like.
Not in the specification `VipAISPPayments_v4` 1.0.1, read 2026-11-11:

- **Rate limits**: the specification documents a 429, but no limit and no `Retry-After`. The portal lists "usage
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 and lifetime**: whether it can be read-only, how long it lives. A sandbox token
lives 1200 seconds.
- **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 @@ -42,7 +41,18 @@ Mostly behind the portal, so expected once the registration is approved
- **Micronaut**: whether it earns a module or a section in the docs
([#19](https://github.com/landsman/moneta-api-java/issues/19)).

Answered by the specification: no movement has a time of day
([#9](https://github.com/landsman/moneta-api-java/issues/9)); the sandbox is the same host with a client
credentials token.

## Not covered

Payments (PISP), statements, standing orders, card transactions as their own endpoint, OAuth client credentials
for the sandbox. Each waits for the specification.
- **Statements**: the specification is in hand. `…/{id}/statements?from=&to=` lists them;
`…/{id}/statement/download` with `format=JSON&feature=JSON$STND` returns one with its movements, other formats
a `dataDownloadURL` for `…/statement?file=`.
- **Payments and standing orders** (`VipPISPPaymentsBatch_v4`, `VipPISPStandingOrders_v4`): each creates an item
in Internet Banka that a person signs or rejects, so a client returns "waiting for a signature", never "paid".
A batch holds up to 200 payments and has a status endpoint. Their specifications are not downloaded yet.
- **Card transactions as their own endpoint**: none in `VipAISPPayments_v4`.
- **A sandbox mode**: a sandbox token works as any `AccessToken`; renewing it every 20 minutes is left to the
caller until someone needs more.
7 changes: 3 additions & 4 deletions modules/moneta-api-micronaut/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,9 @@ None is needed against production. The names are the Spring Boot starter's.

| Property | Default | |
|----------|---------|---|
| `moneta.base-uri` | production, `MonetaClient.PRODUCTION` | the sandbox's host once the portal gives it |
| `moneta.base-uri` | production, `MonetaClient.PRODUCTION` | a test server; the sandbox is the same host, chosen by its token |
| `moneta.request-timeout` | the client's, 30 s | one call to MONETA: `20s` |
| `moneta.partner.identification-number`, `.business-name`, `.application-name` | none | the registered application's headers, all three or none, ASCII only |

MONETA does not ask for the partner headers: every call so far went through without ([how it
behaves](../../.docs/moneta-api.md)). Likely for an application approved on the developer portal; set them once the
portal says so.
MONETA does not ask for the partner headers: its specification marks them optional, and every live call went
through without ([how it behaves](../../.docs/moneta-api.md)).
7 changes: 3 additions & 4 deletions modules/moneta-api-play/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,9 @@ None is needed against production. The keys are the Spring Boot starter's.

| Key | Default | |
|-----|---------|---|
| `moneta.base-uri` | production, `MonetaClient.PRODUCTION` | the sandbox's host once the portal gives it |
| `moneta.base-uri` | production, `MonetaClient.PRODUCTION` | a test server; the sandbox is the same host, chosen by its token |
| `moneta.request-timeout` | the client's, 30 s | one call to MONETA, as a HOCON duration: `20s` |
| `moneta.partner.identification-number`, `.business-name`, `.application-name` | none | the registered application's headers, all three or none, ASCII only |

MONETA does not ask for the partner headers: every call so far went through without ([how it
behaves](../../.docs/moneta-api.md)). Likely for an application approved on the developer portal; set them once the
portal says so.
MONETA does not ask for the partner headers: its specification marks them optional, and every live call went
through without ([how it behaves](../../.docs/moneta-api.md)).
7 changes: 3 additions & 4 deletions modules/moneta-api-spring-boot-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,9 @@ None is needed against production.

| Property | Default | |
|----------|---------|---|
| `moneta.base-uri` | production, `MonetaClient.PRODUCTION` | the sandbox's host once the portal gives it |
| `moneta.base-uri` | production, `MonetaClient.PRODUCTION` | a test server; the sandbox is the same host, chosen by its token |
| `moneta.request-timeout` | the client's, 30 s | one call to MONETA |
| `moneta.partner.identification-number`, `.business-name`, `.application-name` | none | the registered application's headers, all three or none, ASCII only |

MONETA does not ask for the partner headers: every call so far went through without ([how it
behaves](../../.docs/moneta-api.md)). Likely for an application approved on the developer portal; set them once the
portal says so.
MONETA does not ask for the partner headers: its specification marks them optional, and every live call went
through without ([how it behaves](../../.docs/moneta-api.md)).
11 changes: 10 additions & 1 deletion modules/moneta-api/src/main/java/cz/insuit/moneta/Balance.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cz.insuit.moneta;

import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.List;

/**
Expand All @@ -18,7 +19,15 @@ static Balance from(String what, BalancesJson json) {
return new Balance(
CreditDebit.of(booked.creditDebitIndicator()).signed(booked.amount()),
CreditDebit.of(available.creditDebitIndicator()).signed(available.amount()),
available.date().dateTime());
instant(what, available.date()));
}

private static Instant instant(String what, DateTimeValueJson date) {
try {
return date.instant();
} catch (DateTimeParseException e) {
throw new MonetaException.InvalidResponse(what + " sent the balance date " + date.dateTime(), e);
}
}

private static BalanceJson ofType(String what, List<BalanceJson> balances, BalanceCode code) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,24 @@
import static java.util.Objects.requireNonNull;

import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;

/**
* MONETA's {@code {"dateTime": …}}, which its specification calls "date or date and time". Live it has been an
* instant in UTC; a bare date is read as the start of that day in Prague.
*/
record DateTimeValueJson(String dateTime) {
private static final ZoneId PRAGUE = ZoneId.of("Europe/Prague");

record DateTimeValueJson(Instant dateTime) {
DateTimeValueJson {
requireNonNull(dateTime, "dateTime");
}

Instant instant() {
return dateTime.length() == "yyyy-MM-dd".length()
? LocalDate.parse(dateTime).atStartOfDay(PRAGUE).toInstant()
: OffsetDateTime.parse(dateTime).toInstant();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* @param message the payer's message, sent once booked; while an instant payment is pending, "Okamžitá platba"
* @param counterpartyName the payer of a credit, the payee of a debit; sent once booked, for credits
* @param counterpartyAccount the other side's account as MONETA writes it, {@code number/bank}
* @param reversed whether MONETA marks the movement as a reversal
* @param reversed whether MONETA marks the movement as a cancellation of another one; not seen live yet
* @param method how the money moved: a transfer, a card payment, or {@code OTHER} for anything not mapped yet. A
* card payment is a debit whose counterparty name is the merchant and which has no counterparty account; the
* merchant text differs between the pending hold and the booked payment (live, 2026-11-07 and 2026-11-10)
Expand Down
Loading
Loading