diff --git a/.docs/access.md b/.docs/access.md index eaec7e8..bee3ec5 100644 --- a/.docs/access.md +++ b/.docs/access.md @@ -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 diff --git a/.docs/development.md b/.docs/development.md index f4027c8..874b827 100644 --- a/.docs/development.md +++ b/.docs/development.md @@ -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 diff --git a/.docs/moneta-api.md b/.docs/moneta-api.md index 1174227..66ac439 100644 --- a/.docs/moneta-api.md +++ b/.docs/moneta-api.md @@ -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. @@ -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 @@ -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 `, 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 `, 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. @@ -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, @@ -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 @@ -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 @@ -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 diff --git a/.docs/plan/roadmap.md b/.docs/plan/roadmap.md index 4912dce..846aee7 100644 --- a/.docs/plan/roadmap.md +++ b/.docs/plan/roadmap.md @@ -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. @@ -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. diff --git a/modules/moneta-api-micronaut/README.md b/modules/moneta-api-micronaut/README.md index 75f5ca5..667d0a8 100644 --- a/modules/moneta-api-micronaut/README.md +++ b/modules/moneta-api-micronaut/README.md @@ -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)). diff --git a/modules/moneta-api-play/README.md b/modules/moneta-api-play/README.md index 831c85b..83fe268 100644 --- a/modules/moneta-api-play/README.md +++ b/modules/moneta-api-play/README.md @@ -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)). diff --git a/modules/moneta-api-spring-boot-starter/README.md b/modules/moneta-api-spring-boot-starter/README.md index 0f61698..7062dff 100644 --- a/modules/moneta-api-spring-boot-starter/README.md +++ b/modules/moneta-api-spring-boot-starter/README.md @@ -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)). diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/Balance.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/Balance.java index 7ffd7b6..b251139 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/Balance.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/Balance.java @@ -1,6 +1,7 @@ package cz.insuit.moneta; import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.List; /** @@ -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 balances, BalanceCode code) { diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/DateTimeValueJson.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/DateTimeValueJson.java index 91f469d..3cc559c 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/DateTimeValueJson.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/DateTimeValueJson.java @@ -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(); + } } diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/Transaction.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/Transaction.java index 3557006..80770eb 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/Transaction.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/Transaction.java @@ -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) 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 1acea1c..8f14607 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 @@ -345,6 +345,39 @@ void anOverdrawnBalanceIsBelowZero() { assertEquals(new Money(new BigDecimal("87.50"), CZK), balance.available()); } + @Test + void aBalanceDatedWithoutATimeStartsThatDayInPrague() { + // The specification allows "date or date and time"; live has always sent the time so far. + // language=JSON + var dateOnly = """ + {"balances": [ + {"amount": {"currency": "CZK", "value": 1}, "creditDebitIndicator": "CRDT", + "date": {"dateTime": "2026-11-07"}, "type": {"codeOrProprietary": {"code": "CLBD"}}}, + {"amount": {"currency": "CZK", "value": 1}, "creditDebitIndicator": "CRDT", + "date": {"dateTime": "2026-11-07"}, "type": {"codeOrProprietary": {"code": "CLAV"}}} + ]}"""; + answer(BALANCE, 200, dateOnly); + + assertEquals( + Instant.parse("2026-11-06T23:00:00Z"), + client.balance(TOKEN, "acc-1").at()); + } + + @Test + void aBalanceWithAnUnreadableDateIsRefused() { + // language=JSON + var garbled = """ + {"balances": [ + {"amount": {"currency": "CZK", "value": 1}, "creditDebitIndicator": "CRDT", + "date": {"dateTime": "yesterday"}, "type": {"codeOrProprietary": {"code": "CLBD"}}}, + {"amount": {"currency": "CZK", "value": 1}, "creditDebitIndicator": "CRDT", + "date": {"dateTime": "yesterday"}, "type": {"codeOrProprietary": {"code": "CLAV"}}} + ]}"""; + answer(BALANCE, 200, garbled); + + assertThrows(MonetaException.InvalidResponse.class, () -> client.balance(TOKEN, "acc-1")); + } + @Test void aBalanceWithoutTheAvailableOneIsRefused() { // language=JSON diff --git a/spec/.gitignore b/spec/.gitignore new file mode 100644 index 0000000..e156003 --- /dev/null +++ b/spec/.gitignore @@ -0,0 +1,4 @@ +# MONETA's specifications, downloaded from the developer portal. Not committed until its terms +# say they may be republished; see .docs/development.md. +* +!.gitignore