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
46 changes: 46 additions & 0 deletions .docs/moneta-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,52 @@ An instant payment visible in Internet Banka at once stayed pending for at least
three days later over a weekend. Internet Banka's CSV export matches the API field for field ("Bankovní
reference" is `entryReference`), and shows the same "Okamžitá platba" while pending.

### A card payment

One card payment, read while pending on 2026-11-07
([`live/pending-card.json`](../modules/moneta-api/src/test/resources/moneta/live/pending-card.json), the account,
reference and merchant replaced) and again once booked on 2026-11-10, the first working day after it
([`live/booked-card.json`](../modules/moneta-api/src/test/resources/moneta/live/booked-card.json), the account and
symbols replaced; the card number comes masked from MONETA and the merchant is kept).

| Field | Pending card payment | Pending transfer, for comparison |
|-------|----------------------|----------------------------------|
| `clearingSystemReference` | `BLOCKED_PAYMENT_CARD` | `BLOCKED_PAYMENT_ORDER_DOMESTIC` |
| `bankTransactionCode.proprietary.code` | `30000301000` | `10000301000` |
| `creditor.name` | the merchant descriptor, 40 characters | missing |
| `creditorAccount` | `{"identification": {}}` | the payee's account |
| `unstructured` | missing | "Okamžitá platba" on an instant payment |
| symbols (`reference`) | `""` | `"VS:"` |
| `amount.value` | `30.0` | `1` |

- **The pending merchant descriptor is fixed-width**: 25 characters of name, 13 of city, 2 of country, padded
with spaces, as card networks lay out the card acceptor's name and location. A payment facilitator puts its own
prefix before `*` in the name. Read from one payment, so the widths are an inference.
- **No card number, no merchant category, no time** while pending.
- **What the code's digits mean is not published** by MONETA or found in a ČNB or ČBA document; only the first
digit has differed so far, 1 for a transfer and 3 for a card.
- **A pending card payment is a hold.** It may lapse without a booked payment; not observed yet.

Once booked, the same payment changed this much:

| Field | Pending | Booked |
|-------|---------|--------|
| `status`, `bookingDate` | `PDNG`, `null` | `BOOK`, `2026-11-10` |
| `clearingSystemReference` | `BLOCKED_PAYMENT_CARD` | `PAYMENT_CARD` |
| `bankTransactionCode.proprietary.code` | `30000301000` | `30000201000` |
| the merchant | `creditor.name`, 40 characters at fixed widths | `transactionDescription`, 30 characters, another text |
| `chequeNumber` | missing | the card number, masked by MONETA: `478544******1033` |
| `additionalTransactionInformation`, `purpose.proprietary` | missing | `PLATBA KARTOU ONLINE`, `Služby` |
| `endToEndIdentification`, symbols (`reference`) | missing, `""` | present |
| `amount.value`, `enteredDate`, `valueDate` | `30.0`, `2026-11-07`, `null` | `30`, `2026-11-07`, `null` |

- **The booked amount matched the hold**, and the booked movement replaced the pending one: the count stayed the
same, nothing was left pending.
- **The merchant moves**: `counterpartyName()` reads `creditor.name` and falls back to `transactionDescription`
for a card payment. The booked text is not the pending descriptor shortened, so neither is split into fields.
- **The code's sixth digit went from 3 to 2** on booking; a transfer's pending code ends `0301000` too, so it may
mean pending. One payment, an inference.

### The rest

- **A debit is `DBIT` with a positive value**; the direction carries the sign.
Expand Down
3 changes: 2 additions & 1 deletion .docs/plan/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ behaves, as far as it is known, is [moneta-api.md](../moneta-api.md).
- **Released:** `0.0.1` on GitHub Packages, the client and its Spring Boot starter, as a pre-release
([install](../install.md)).
- **The client reads** accounts and transactions, pending and booked, with symbols, message, counterparty and
`incoming()`. Checked against a live business account on 2026-11-06 and 2026-11-07.
`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.
Expand Down
181 changes: 115 additions & 66 deletions .docs/usage.md
Original file line number Diff line number Diff line change
@@ -1,72 +1,121 @@
# Using the client

```java
var moneta = new MonetaClient(); // production, nothing to set

for (var account : moneta.accounts(token)) {
for (var t :
moneta.transactions(token, account.id(), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31))) {
if (t.booked()) {
System.out.println(t.amount() + " VS " + t.symbols().variable());
}
}
var moneta = new MonetaClient(); // one per application
var me = moneta.with(new AccessToken(myToken)); // cheap, keep it or make one per request

var november = me.transactions().between(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 30));

november.list(); // everything
november.incoming().booked().list(); // money that came in, booked
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
```

<details><summary>Kotlin</summary>

```kotlin
val moneta = MonetaClient() // one per application
val me = moneta.with(AccessToken(myToken)) // cheap, keep it or make one per request

val november = me.transactions().between(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 30))

november.list() // everything
november.incoming().booked().list() // money that came in, booked
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
```

</details>

With the Spring Boot starter, `moneta.token` gives you the `AccessToken` bean.

## Filtering

| Step | Keeps |
|------|-------|
| `between(from, to)` | days, both inclusive; **required** |
| `transfers()`, `cards()`, `method(…)` | how the money moved |
| `incoming()`, `outgoing()` | money in, money out |
| `booked()`, `pending()` | booked, pending |

- Steps are optional and combine; a later one of the same kind wins.
- A query is immutable, so one base query can branch, as `november` above.
- Nothing is read until `list()`. MONETA filters by date only, so a shorter range is what makes it cheaper.

## 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:

```java
for (var account : me.accounts()) {
me.transactions(account.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()
}
```

- **One client for many merchants.** The token goes with each call as an `AccessToken` and is never stored,
logged or put in an exception message. Its own type keeps it from being swapped with the account id, and it
refuses a value with a space or a control character, without repeating it.
- **A movement's `entryReference` changes when it is booked.** Pending and booked, the same payment carries two
different ids, so deduplicating by it counts the payment twice. Act on booked movements only, or match the
booked one to the pending one by symbol, amount and counterparty account, as `examples/cafe` does.
- **Once booked a movement carries `message()`** (what the payer wrote) **and `counterpartyName()`**; pending, the
message is "Okamžitá platba" on an instant payment and the name is missing. `counterpartyAccount()` is there
in both. All three stay out of `toString()`.
- **Which date is the payment's**: `enteredDate()` while pending, `valueDate()` once booked. `bookingDate()` can
be a later business day, and `enteredDate()` is null on a booked credit. [How MONETA API behaves](moneta-api.md)
has the whole table.
- **No time of day**: a movement has dates only. To tell "paid in the last minute", store when your poller
first saw each `entryReference`; polling every few seconds makes that as close as it gets.
`examples/cafe` keeps it as `first_seen_at`.
- **`balance(token, accountId)`** gives the booked and the available balance, signed, and `at()`, the instant
MONETA computed it: unlike a movement, a balance has a time of day.
- **`reversed()`** marks a movement MONETA flags as a reversal; a shop should not count it as a payment.
- **`incoming(…)`** is `transactions(…)` with only the money that came in, the call a shop checking payments
makes. Debits are dropped by the client; MONETA has no such filter that is known.
- **Amounts are `Money`**, a `BigDecimal` with a `Currency`, parsed without a detour through `double`. MONETA
sends them positive beside a direction; `amount()` is signed, negative for money out, so amounts add up as
they are. `1500` and `1500.00` CZK are equal.
- **The status is an enum**, `BOOKED`, `PENDING` or `OTHER` for a code not known yet. An unknown direction is
refused instead: a wrong sign on money is worse than a failed read.
- **Payment symbols** come in one string, `"VS:123456 KS:0308 SS:0001"` or comma-separated. `PaymentSymbols`
reads either and keeps leading zeros.
- **`raw`** holds each transaction as JSON, for the fields not mapped yet. It, the message and the counterparty
are left out of `toString()`, so logging a transaction does not log who paid. A JSON serialiser still writes it,
name and IBAN of the counterparty included, so map a `Transaction` to your own type before returning it from
a controller or logging it as JSON.
- **A read is all or nothing.** A 429 on page five discards pages one to four, and a retry starts over. Read a
long range month by month.
- **One client per application.** It is `AutoCloseable`; `close()` releases the HTTP client it made, not one
you passed in.
- **Your own HTTP stack**: `configureHttpClient(…)` for a proxy or mTLS, `decorateHttpClient(…)` for tracing or
metrics, both keeping the client's own settings and its `close()`. [Compatibility](compatibility.md#your-own-http-stack)
has the examples and the traps.
- **Failures** are a sealed `MonetaException`, one subtype per thing to do about it, so a `switch` over them is
exhaustive: `InvalidToken` (401, 403) needs a new token, `RateLimited` (429) carries `retryAfter()`,
`HttpError` any other status, `NoAnswer` a timeout or a refused connection, `InvalidResponse` a body that is
not the expected shape. `InvalidToken` and `HttpError` keep the start of MONETA's answer in `body()`, out of
the message. The constructors are public, so a fake in your tests can throw them.
- **An interrupted thread is cancelled, not failed:** a `CancellationException` with the interrupt flag kept,
outside `MonetaException`, so a retry on `NoAnswer` does not repeat work that was cancelled on purpose, and a
Kotlin coroutine reads it as cancellation.
- **Records may gain components in a minor version** before 1.0. Read them by accessor; a record pattern over
`Transaction` or `Account` would break on the next field.

---

## Optional settings

`MonetaClient.builder()` sets what differs from production: `baseUri(…)` for the sandbox, `requestTimeout(…)`, and
`partner(…)`, the three headers that name a registered application. MONETA does not ask for the partner headers:
every call so far went through without ([how it behaves](moneta-api.md)). Likely for an application approved on the
developer portal; set them once the portal says so.
</details>

Several account holders, each with their own token, share one client:

```java
holders.forEach(holder -> moneta.with(holder.token()).transactions().between(from, to).list());
```

<details><summary>Kotlin</summary>

```kotlin
holders.forEach { moneta.with(it.token).transactions().between(from, to).list() }
```

</details>

## What to know

- **`entryReference` changes when a movement is booked**: deduplicate by it and a payment counts twice. Act on
booked movements, or match by symbols, amount and counterparty account, as `examples/cafe` does.
- **Dates only, no time of day.** The payment's date is `enteredDate()` while pending, `valueDate()` once booked.
Need "paid a minute ago"? Store when you first saw it.
- **`amount()` is signed**: negative is money out, so amounts add up. `Money` is a `BigDecimal` with a currency.
- **`method()`** is `TRANSFER`, `CARD` or `OTHER`, and gains constants as they are seen live
([#66](https://github.com/landsman/moneta-api-java/issues/66)): keep a `default` in a `switch`.
- **`symbols()`** reads `VS`, `KS` and `SS`, leading zeros kept.
- **`reversed()`** is a reversal, not a payment.
- **`raw()`** has the whole movement as JSON. It, `message()` and the counterparty stay out of `toString()`, not
out of a JSON serialiser: map to your own type before returning one from a controller.
- **A read is all or nothing**, every page or a failure. Read long ranges month by month.
- **Records may gain components before 1.0**: use accessors, not record patterns.

[How MONETA API behaves](moneta-api.md) has the details behind each.

## Failures

A sealed `MonetaException`, so a `switch` over it is exhaustive:

| Subtype | When | Do |
|---------|------|----|
| `InvalidToken` | 401, 403 | get a new token |
| `RateLimited` | 429 | wait `retryAfter()` |
| `HttpError` | any other status | `body()` has MONETA's answer |
| `NoAnswer` | timeout, refused connection | retry |
| `InvalidResponse` | an unexpected body | report it |

An interrupted thread throws `CancellationException` instead, interrupt flag kept.

## Settings

`MonetaClient.builder()`: `baseUri(…)`, `requestTimeout(…)`, `partner(…)` (headers MONETA does not ask for yet),
`configureHttpClient(…)` for a proxy or mTLS, `decorateHttpClient(…)` for tracing or metrics
([examples](compatibility.md#your-own-http-stack)). `close()` releases the HTTP client it made.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ client implements:
|---|:---:|:---:|
| Accounts the token sees | ✅ | ✅ |
| Transactions, pending and booked | ✅ | ✅ |
| Card transactions | ✅ | ⬜ |
| Card transactions | ✅ | in the transaction list, `method()`; not their own endpoint |
| Balances | ✅ | ✅ |
| Pending card transactions | ✅ | ⬜ |
| Electronic statements | ✅ | ⬜ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ void theBookedVersionBooksThePendingRowWhateverDayItCarries() {
"Novak, Jan",
"0 4040404040/0800",
false,
Transaction.Method.TRANSFER,
"{}");

assertEquals(
Expand All @@ -109,6 +110,7 @@ void aReversalIsNotRecorded() {
null,
"0 4040404040/0800",
true,
Transaction.Method.TRANSFER,
"{}");

assertEquals(List.of(), PaymentRecording.steps(List.of(reversal), Set.of(), List.of()));
Expand All @@ -135,6 +137,7 @@ private static Transaction pending(String reference, @Nullable String symbol, St
null,
account,
false,
Transaction.Method.TRANSFER,
"{}");
}

Expand All @@ -152,6 +155,7 @@ private static Transaction booked(String reference, @Nullable String symbol, Str
"Novak, Jan",
account,
false,
Transaction.Method.TRANSFER,
"{}");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ private static Transaction pending(String reference) {
null,
"4040404040/0800",
false,
Transaction.Method.TRANSFER,
"{}");
}

Expand All @@ -126,6 +127,7 @@ private static Transaction booked(String reference) {
"Novak, Jan",
"0 4040404040/0800",
false,
Transaction.Method.TRANSFER,
"{}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cz.insuit.moneta;

import static java.util.Objects.requireNonNull;

import java.util.List;
import java.util.concurrent.CancellationException;
import org.jspecify.annotations.Nullable;

/**
* A {@link MonetaClient} with one holder's token bound, from {@link MonetaClient#with(AccessToken)}:
*
* <pre>{@code
* var me = moneta.with(token);
* me.transactions().between(from, to).cards().list();
* me.balance();
* }</pre>
*
* Holds the client and the token only, so making one per request costs nothing. The calls without an account id
* read the only account the token opens and remember its id, so a holder kept between calls asks for it once.
* Safe to share between threads.
*/
public final class AccountHolder {

private final MonetaClient client;
private final AccessToken token;
// ponytail: remembered for this holder's life; an account closed meanwhile surfaces as MONETA's own error
private volatile @Nullable String onlyAccountId;

AccountHolder(MonetaClient client, AccessToken token) {
this.client = requireNonNull(client);
this.token = requireNonNull(token, "token");
}

/** The movements on the only account the token opens; see {@link TransactionQuery}. */
public TransactionQuery transactions() {
return new TransactionQuery(client, token, this::onlyAccountId);
}

/** The movements on this account; see {@link TransactionQuery}. */
public TransactionQuery transactions(String accountId) {
return client.transactions(token, accountId);
}

/**
* The only account's balance.
*
* @throws IllegalStateException when the token opens other than exactly one account
* @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 Balance balance() {
return client.balance(token, onlyAccountId());
}

/** As {@link MonetaClient#balance(AccessToken, String)}. */
public Balance balance(String accountId) {
return client.balance(token, accountId);
}

/** As {@link MonetaClient#accounts(AccessToken)}. */
public List<Account> accounts() {
return client.accounts(token);
}

private String onlyAccountId() {
var id = onlyAccountId;
if (id == null) {
id = client.onlyAccount(token);
onlyAccountId = id;
}
return id;
}
}
Loading
Loading