diff --git a/.docs/moneta-api.md b/.docs/moneta-api.md
index 6ef3dbf..1174227 100644
--- a/.docs/moneta-api.md
+++ b/.docs/moneta-api.md
@@ -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.
diff --git a/.docs/plan/roadmap.md b/.docs/plan/roadmap.md
index 5ec06ee..4912dce 100644
--- a/.docs/plan/roadmap.md
+++ b/.docs/plan/roadmap.md
@@ -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.
diff --git a/.docs/usage.md b/.docs/usage.md
index b6d55c3..af0360b 100644
--- a/.docs/usage.md
+++ b/.docs/usage.md
@@ -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
+```
+
+Kotlin
+
+```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
+```
+
+
+
+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();
+}
+```
+
+Kotlin
+
+```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.
+
+
+Several account holders, each with their own token, share one client:
+
+```java
+holders.forEach(holder -> moneta.with(holder.token()).transactions().between(from, to).list());
+```
+
+Kotlin
+
+```kotlin
+holders.forEach { moneta.with(it.token).transactions().between(from, to).list() }
+```
+
+
+
+## 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.
diff --git a/README.md b/README.md
index 33e1dd6..7339d7a 100644
--- a/README.md
+++ b/README.md
@@ -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 | ✅ | ⬜ |
diff --git a/examples/cafe/src/test/java/example/cafe/payment/PaymentRecordingTest.java b/examples/cafe/src/test/java/example/cafe/payment/PaymentRecordingTest.java
index 6de78a1..331b3f2 100644
--- a/examples/cafe/src/test/java/example/cafe/payment/PaymentRecordingTest.java
+++ b/examples/cafe/src/test/java/example/cafe/payment/PaymentRecordingTest.java
@@ -87,6 +87,7 @@ void theBookedVersionBooksThePendingRowWhateverDayItCarries() {
"Novak, Jan",
"0 4040404040/0800",
false,
+ Transaction.Method.TRANSFER,
"{}");
assertEquals(
@@ -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()));
@@ -135,6 +137,7 @@ private static Transaction pending(String reference, @Nullable String symbol, St
null,
account,
false,
+ Transaction.Method.TRANSFER,
"{}");
}
@@ -152,6 +155,7 @@ private static Transaction booked(String reference, @Nullable String symbol, Str
"Novak, Jan",
account,
false,
+ Transaction.Method.TRANSFER,
"{}");
}
diff --git a/examples/cafe/src/test/java/example/cafe/payment/PaymentWritesTest.java b/examples/cafe/src/test/java/example/cafe/payment/PaymentWritesTest.java
index 69e4552..96b68da 100644
--- a/examples/cafe/src/test/java/example/cafe/payment/PaymentWritesTest.java
+++ b/examples/cafe/src/test/java/example/cafe/payment/PaymentWritesTest.java
@@ -109,6 +109,7 @@ private static Transaction pending(String reference) {
null,
"4040404040/0800",
false,
+ Transaction.Method.TRANSFER,
"{}");
}
@@ -126,6 +127,7 @@ private static Transaction booked(String reference) {
"Novak, Jan",
"0 4040404040/0800",
false,
+ Transaction.Method.TRANSFER,
"{}");
}
}
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
new file mode 100644
index 0000000..cadd4d7
--- /dev/null
+++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java
@@ -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)}:
+ *
+ *
{@code
+ * var me = moneta.with(token);
+ * me.transactions().between(from, to).cards().list();
+ * me.balance();
+ * }
+ *
+ * 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 accounts() {
+ return client.accounts(token);
+ }
+
+ private String onlyAccountId() {
+ var id = onlyAccountId;
+ if (id == null) {
+ id = client.onlyAccount(token);
+ onlyAccountId = id;
+ }
+ return id;
+ }
+}
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 3e98492..71dedcc 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
@@ -152,7 +152,7 @@ public Balance balance(AccessToken token, String accountId) {
}
/**
- * The money that came in: {@link #transactions} with only {@link Transaction.Direction#CREDIT}, pending ones
+ * 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
* dropped here.
*
@@ -167,6 +167,31 @@ public List incoming(AccessToken token, String accountId, LocalDate
.toList();
}
+ /**
+ * This client with the token bound, so the calls after it need none:
+ * {@code moneta.with(token).transactions().between(from, to).cards().list()}. Cheap: it holds the client and the
+ * token, and reads nothing until a call. Keep it to have the only account's id remembered.
+ */
+ public AccountHolder with(AccessToken token) {
+ return new AccountHolder(this, token);
+ }
+
+ /** A query over the account's movements; see {@link TransactionQuery}. Nothing is read until its {@code list()}. */
+ public TransactionQuery transactions(AccessToken token, String accountId) {
+ requireNonNull(accountId, "accountId");
+ return new TransactionQuery(this, token, () -> accountId);
+ }
+
+ /** The id of the only account the token opens; {@link IllegalStateException} unless there is exactly one. */
+ String onlyAccount(AccessToken token) {
+ var accounts = accounts(token);
+ if (accounts.size() != 1) {
+ throw new IllegalStateException(
+ "the token opens " + accounts.size() + " accounts, so name one: transactions(accountId)");
+ }
+ return accounts.getFirst().id();
+ }
+
/**
* Every transaction on the account between two dates, both inclusive, all pages read, pending ones included
* ({@link Transaction#booked()} tells them apart). MONETA filters a pending movement by its entered date; a
diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java
new file mode 100644
index 0000000..866a8a6
--- /dev/null
+++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java
@@ -0,0 +1,13 @@
+package cz.insuit.moneta;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * How the movement was made, and a booked card payment's merchant.
+ *
+ * @param clearingSystemReference {@code PAYMENT_ORDER_DOMESTIC} for a transfer, {@code PAYMENT_CARD} for a card
+ * payment, each prefixed {@code BLOCKED_} while pending (live, 2026-11-06 to 2026-11-10)
+ * @param transactionDescription a booked card payment's merchant, which the pending one sends as the creditor
+ */
+record ReferencesJson(
+ @Nullable String clearingSystemReference, @Nullable String transactionDescription) {}
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 0c51657..3557006 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
@@ -20,6 +20,9 @@
* @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 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)
* @param raw the movement as MONETA sent it, re-serialised as JSON (key order and number formatting may
* differ), for whatever this record does not map. It, the message and the counterparty are left out of
* {@link #toString()}, so logging a transaction does not log who paid or what they wrote
@@ -37,6 +40,7 @@ public record Transaction(
@Nullable String counterpartyName,
@Nullable String counterpartyAccount,
boolean reversed,
+ Method method,
String raw) {
/** Whether the movement is booked and will not change. */
@@ -46,7 +50,7 @@ public boolean booked() {
@Override
public String toString() {
- return "Transaction[entryReference=%s, status=%s, direction=%s, amount=%s, enteredDate=%s, bookingDate=%s, valueDate=%s, symbols=%s, reversed=%s]"
+ return "Transaction[entryReference=%s, status=%s, direction=%s, amount=%s, enteredDate=%s, bookingDate=%s, valueDate=%s, symbols=%s, reversed=%s, method=%s]"
.formatted(
entryReference,
status,
@@ -56,7 +60,8 @@ public String toString() {
bookingDate,
valueDate,
symbols,
- reversed);
+ reversed,
+ method);
}
/** Which way the money moved; {@link #amount()} already carries it as the sign. */
@@ -65,6 +70,20 @@ public enum Direction {
DEBIT
}
+ /**
+ * How the money moved, read from MONETA's {@code clearingSystemReference}. {@code OTHER} for any other value,
+ * which {@link #raw()} keeps.
+ */
+ // TODO: fees, interest, ATM withdrawals and the rest, once one is seen live:
+ // https://github.com/landsman/moneta-api-java/issues/66
+ public enum Method {
+ /** A domestic payment order, {@code PAYMENT_ORDER_DOMESTIC}; an instant one included. */
+ TRANSFER,
+ /** A card payment, {@code PAYMENT_CARD}; while pending, a hold that may lapse. */
+ CARD,
+ OTHER
+ }
+
/** {@code OTHER} for a code this library does not know yet; {@link #raw()} keeps it. */
public enum Status {
BOOKED,
@@ -95,6 +114,7 @@ static Transaction from(TransactionJson json, String raw) {
json.counterpartyName(direction == Direction.CREDIT),
json.counterpartyAccount(direction == Direction.CREDIT),
Boolean.TRUE.equals(json.reversalIndicator()),
+ json.method(),
raw);
}
diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionDetailsJson.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionDetailsJson.java
index 4e46519..c8d8d33 100644
--- a/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionDetailsJson.java
+++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionDetailsJson.java
@@ -3,5 +3,6 @@
import org.jspecify.annotations.Nullable;
record TransactionDetailsJson(
+ @Nullable ReferencesJson references,
@Nullable RemittanceInformationJson remittanceInformation,
@Nullable RelatedPartiesJson relatedParties) {}
diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionJson.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionJson.java
index 398f15c..4d8f3e1 100644
--- a/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionJson.java
+++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionJson.java
@@ -38,12 +38,19 @@ String message() {
return remittance().map(RemittanceInformationJson::unstructured).orElse(null);
}
- /** The other side's name: the debtor of a credit, the creditor of a debit. Sent once booked. */
+ /**
+ * The other side's name: the debtor of a credit, the creditor of a debit. Sent once booked; a card payment sends
+ * its merchant as the creditor while pending and only in {@code transactionDescription} once booked.
+ */
@Nullable
String counterpartyName(boolean credit) {
return parties()
.map(parties -> credit ? parties.debtor() : parties.creditor())
.map(PartyJson::name)
+ .or(() -> method() == Transaction.Method.CARD
+ ? references().map(ReferencesJson::transactionDescription)
+ : Optional.empty())
+ .filter(name -> !name.isBlank())
.orElse(null);
}
@@ -58,10 +65,27 @@ String counterpartyAccount(boolean credit) {
.orElse(null);
}
+ /** How the money moved; a pending movement's reference carries a {@code BLOCKED_} prefix. */
+ Transaction.Method method() {
+ var reference = references()
+ .map(ReferencesJson::clearingSystemReference)
+ .map(r -> r.startsWith("BLOCKED_") ? r.substring("BLOCKED_".length()) : r)
+ .orElse("");
+ return switch (reference) {
+ case "PAYMENT_ORDER_DOMESTIC" -> Transaction.Method.TRANSFER;
+ case "PAYMENT_CARD" -> Transaction.Method.CARD;
+ default -> Transaction.Method.OTHER;
+ };
+ }
+
private Optional details() {
return Optional.ofNullable(entryDetails).map(EntryDetailsJson::transactionDetails);
}
+ private Optional references() {
+ return details().map(TransactionDetailsJson::references);
+ }
+
private Optional remittance() {
return details().map(TransactionDetailsJson::remittanceInformation);
}
diff --git a/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java
new file mode 100644
index 0000000..82417a7
--- /dev/null
+++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java
@@ -0,0 +1,117 @@
+package cz.insuit.moneta;
+
+import static java.util.Objects.requireNonNull;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.function.Supplier;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The movements on one account, narrowed step by step and read by {@link #list()}:
+ *
+ * {@code
+ * moneta.with(token).transactions().between(from, to).transfers().outgoing().list();
+ * }
+ *
+ * Only {@link #between} is required. Each step returns a new query, so a base one can be narrowed two ways; a
+ * later step of the same kind replaces the earlier one ({@code incoming().outgoing()} is outgoing). MONETA filters
+ * by date only, so {@link #list()} reads every movement in the range and drops the rest here.
+ */
+public final class TransactionQuery {
+
+ private final MonetaClient client;
+ private final AccessToken token;
+ private final Supplier accountId;
+ private final @Nullable LocalDate from;
+ private final @Nullable LocalDate to;
+ private final Transaction.@Nullable Method method;
+ private final Transaction.@Nullable Direction direction;
+ private final Transaction.@Nullable Status status;
+
+ TransactionQuery(MonetaClient client, AccessToken token, Supplier accountId) {
+ this(client, token, accountId, null, null, null, null, null);
+ }
+
+ /** Both days inclusive, as {@link MonetaClient#transactions(AccessToken, String, LocalDate, LocalDate)} reads. */
+ public TransactionQuery between(LocalDate from, LocalDate to) {
+ return new TransactionQuery(
+ client, token, accountId, requireNonNull(from), requireNonNull(to), method, direction, status);
+ }
+
+ /** Only domestic payment orders, instant ones included. */
+ public TransactionQuery transfers() {
+ return method(Transaction.Method.TRANSFER);
+ }
+
+ /** Only card payments, pending holds included unless {@link #booked()} follows. */
+ public TransactionQuery cards() {
+ return method(Transaction.Method.CARD);
+ }
+
+ /** Only movements made this way. */
+ public TransactionQuery method(Transaction.Method method) {
+ return new TransactionQuery(client, token, accountId, from, to, requireNonNull(method), direction, status);
+ }
+
+ /** Only money that came in. */
+ public TransactionQuery incoming() {
+ return new TransactionQuery(client, token, accountId, from, to, method, Transaction.Direction.CREDIT, status);
+ }
+
+ /** Only money that went out. */
+ public TransactionQuery outgoing() {
+ return new TransactionQuery(client, token, accountId, from, to, method, Transaction.Direction.DEBIT, status);
+ }
+
+ /** Only booked movements, which will not change. */
+ public TransactionQuery booked() {
+ return new TransactionQuery(client, token, accountId, from, to, method, direction, Transaction.Status.BOOKED);
+ }
+
+ /** Only pending movements. */
+ public TransactionQuery pending() {
+ return new TransactionQuery(client, token, accountId, from, to, method, direction, Transaction.Status.PENDING);
+ }
+
+ /**
+ * Reads the range, all pages, and keeps what every step asked for.
+ *
+ * @throws IllegalStateException when {@link #between} was not called, or the query names no account and the
+ * token opens other than exactly one
+ * @throws IllegalArgumentException when {@code to} is before {@code from}, or the account id is blank or a
+ * dot segment
+ * @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 List list() {
+ if (from == null || to == null) {
+ throw new IllegalStateException("call between(from, to) first: MONETA reads a date range");
+ }
+ return client.transactions(token, accountId.get(), from, to).stream()
+ .filter(t -> method == null || t.method() == method)
+ .filter(t -> direction == null || t.direction() == direction)
+ .filter(t -> status == null || t.status() == status)
+ .toList();
+ }
+
+ private TransactionQuery(
+ MonetaClient client,
+ AccessToken token,
+ Supplier accountId,
+ @Nullable LocalDate from,
+ @Nullable LocalDate to,
+ Transaction.@Nullable Method method,
+ Transaction.@Nullable Direction direction,
+ Transaction.@Nullable Status status) {
+ this.client = requireNonNull(client);
+ this.token = requireNonNull(token);
+ this.accountId = requireNonNull(accountId);
+ this.from = from;
+ this.to = to;
+ this.method = method;
+ this.direction = direction;
+ this.status = status;
+ }
+}
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 d381c25..1acea1c 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
@@ -359,6 +359,126 @@ void aBalanceWithoutTheAvailableOneIsRefused() {
assertTrue(String.valueOf(refused.getMessage()).contains("CLAV"), refused.getMessage());
}
+ @Test
+ void aPendingCardPaymentIsADebitToTheMerchantWithNoAccount() {
+ // Recorded from the live API, the account, the reference and the merchant replaced.
+ answer(TRANSACTIONS, 200, fixture("live/pending-card.json"));
+
+ var card = client.transactions(TOKEN, "acc-1", FROM, TO).getFirst();
+
+ assertEquals(Transaction.Method.CARD, card.method());
+ assertEquals(Transaction.Status.PENDING, card.status());
+ assertEquals(new Money(new BigDecimal("-30.00"), CZK), card.amount());
+ assertEquals(LocalDate.of(2026, 11, 7), card.enteredDate());
+ assertEquals("PAYFAC *MICHAL LANDSMAN PRAHA CZ", card.counterpartyName());
+ assertNull(card.counterpartyAccount());
+ assertNull(card.message());
+ }
+
+ @Test
+ void aBookedCardPaymentKeepsTheHoldsAmountAndNamesTheMerchantFromItsDescription() {
+ // The same payment as pending-card.json, read once booked; the account and the symbols replaced.
+ answer(TRANSACTIONS, 200, fixture("live/booked-card.json"));
+
+ var card = client.transactions(TOKEN, "acc-1", FROM, TO).getFirst();
+
+ assertEquals(Transaction.Method.CARD, card.method());
+ assertEquals(Transaction.Status.BOOKED, card.status());
+ assertEquals(new Money(new BigDecimal("-30.00"), CZK), card.amount());
+ assertEquals(LocalDate.of(2026, 11, 7), card.enteredDate());
+ assertEquals(LocalDate.of(2026, 11, 10), card.bookingDate());
+ assertEquals("SumUp *Studio Art Mic Praha C", card.counterpartyName());
+ assertNull(card.counterpartyAccount());
+ }
+
+ @Test
+ void bookedTransfersAreTransfers() {
+ answer(TRANSACTIONS, 200, fixture("live/booked.json"));
+ assertTrue(client.transactions(TOKEN, "acc-1", FROM, TO).stream()
+ .allMatch(t -> t.method() == Transaction.Method.TRANSFER));
+ }
+
+ @Test
+ void aQueryKeepsOnlyWhatEveryStepAskedFor() {
+ // Two pending transfers, one in and one out.
+ answer(TRANSACTIONS, 200, fixture("live/pending-symbols-and-outgoing.json"));
+ var all = client.transactions(TOKEN, "acc-1", FROM, TO);
+ var query = client.transactions(TOKEN, "acc-1").between(FROM, TO);
+
+ assertEquals(2, query.list().size());
+ assertEquals(1, query.transfers().outgoing().list().size());
+ assertEquals(
+ all.stream()
+ .filter(t -> t.method() == Transaction.Method.TRANSFER
+ && t.direction() == Transaction.Direction.DEBIT)
+ .toList(),
+ query.transfers().outgoing().list());
+ assertEquals(query.outgoing().list(), query.incoming().outgoing().list());
+ assertTrue(query.cards().list().isEmpty());
+ }
+
+ @Test
+ void aQueryFindsTheCardPayment() {
+ answer(TRANSACTIONS, 200, fixture("live/booked-card.json"));
+ var query = client.transactions(TOKEN, "acc-1").between(FROM, TO);
+
+ assertEquals(1, query.cards().outgoing().booked().list().size());
+ assertTrue(query.cards().pending().list().isEmpty());
+ assertTrue(query.transfers().list().isEmpty());
+ }
+
+ @Test
+ void aQueryWithoutAnAccountReadsTheOnlyOne() {
+ answer(ACCOUNTS, 200, """
+ {"accounts": [{"id": "acc-1", "currency": "CZK"}], "pageNumber": 0, "pageCount": 1}""");
+ answer(TRANSACTIONS, 200, fixture("live/booked-card.json"));
+
+ assertEquals(
+ 1,
+ client.with(TOKEN)
+ .transactions()
+ .between(FROM, TO)
+ .cards()
+ .list()
+ .size());
+ }
+
+ @Test
+ void aHolderAsksForItsOnlyAccountOnce() {
+ answer(ACCOUNTS, 200, """
+ {"accounts": [{"id": "acc-1", "currency": "CZK"}], "pageNumber": 0, "pageCount": 1}""");
+ answer(TRANSACTIONS, 200, fixture("live/booked-card.json"));
+ var me = client.with(TOKEN);
+
+ me.transactions().between(FROM, TO).list();
+ me.transactions().between(FROM, TO).cards().list();
+
+ assertEquals(
+ 1,
+ requests.stream()
+ .filter(r -> r.getRequestURI().getPath().equals(ACCOUNTS))
+ .count());
+ }
+
+ @Test
+ void aQueryWithoutAnAccountRefusesATokenWithTwo() {
+ answer(ACCOUNTS, 200, """
+ {"accounts": [{"id": "a", "currency": "CZK"}, {"id": "b", "currency": "EUR"}], "pageNumber": 0, "pageCount": 1}""");
+
+ var refused = assertThrows(
+ IllegalStateException.class,
+ () -> client.with(TOKEN).transactions().between(FROM, TO).list());
+ assertTrue(String.valueOf(refused.getMessage()).contains("opens 2 accounts"), refused.getMessage());
+ }
+
+ @Test
+ void aQueryWithoutDatesIsRefusedBeforeAnyRequest() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> client.transactions(TOKEN, "acc-1").cards().list());
+ assertTrue(requests.isEmpty());
+ }
+
@Test
void keepsTheCounterpartyInRawButOutOfToString() {
answerBothPages();
diff --git a/modules/moneta-api/src/test/resources/moneta/README.md b/modules/moneta-api/src/test/resources/moneta/README.md
index 38cc2ea..10a2cc4 100644
--- a/modules/moneta-api/src/test/resources/moneta/README.md
+++ b/modules/moneta-api/src/test/resources/moneta/README.md
@@ -19,7 +19,7 @@ validator and belong to nobody.
| a payer | `4040404040/0800` | `CZ4008000000004040404040` | 404 Not Found |
| another counterparty | `1010101010/0100` | `CZ2301000000001010101010` | binary |
-A balance's amounts are replaced with `1337`. A movement's `entryReference` is replaced with `2600000000000000001`, counting up for more than one; a name,
+A pending card payment's merchant descriptor is replaced with `MICHAL LANDSMAN` behind a `PAYFAC *` prefix, keeping its widths (the maintainer's own name, his choice); a booked one's merchant and the card number MONETA already masks are kept as sent, also his choice. A balance's amounts are replaced with `1337`. A movement's `entryReference` is replaced with `2600000000000000001`, counting up for more than one; a name,
when one appears, with `Jan Novak`.
Recording one: `make record-fixture N= NAME= FROM=… TO=…` applies these rules and refuses a party
diff --git a/modules/moneta-api/src/test/resources/moneta/live/booked-card.json b/modules/moneta-api/src/test/resources/moneta/live/booked-card.json
new file mode 100644
index 0000000..8f70e0c
--- /dev/null
+++ b/modules/moneta-api/src/test/resources/moneta/live/booked-card.json
@@ -0,0 +1,63 @@
+{
+ "transactions" : [ {
+ "amount" : {
+ "currency" : "CZK",
+ "value" : 30
+ },
+ "bankTransactionCode" : {
+ "proprietary" : {
+ "code" : "30000201000",
+ "issuer" : "CNB"
+ }
+ },
+ "bookingDate" : {
+ "date" : "2026-11-10"
+ },
+ "creditDebitIndicator" : "DBIT",
+ "enteredDate" : {
+ "date" : "2026-11-07"
+ },
+ "entryDetails" : {
+ "transactionDetails" : {
+ "additionalTransactionInformation" : "PLATBA KARTOU ONLINE",
+ "purpose" : {
+ "proprietary" : "Služby"
+ },
+ "references" : {
+ "chequeNumber" : "478544******1033",
+ "clearingSystemReference" : "PAYMENT_CARD",
+ "endToEndIdentification" : "1337",
+ "transactionDescription" : "SumUp *Studio Art Mic Praha C"
+ },
+ "relatedParties" : {
+ "creditorAccount" : {
+ "identification" : { }
+ },
+ "debtorAccount" : {
+ "identification" : {
+ "other" : {
+ "identification" : "1337/0600"
+ }
+ }
+ }
+ },
+ "remittanceInformation" : {
+ "structured" : {
+ "creditorReferenceInformation" : {
+ "reference" : "VS:1337 KS:1000"
+ }
+ }
+ }
+ }
+ },
+ "entryReference" : "0000001337:20261110:00001:26110600000000001",
+ "reversalIndicator" : false,
+ "status" : "BOOK",
+ "valueDate" : {
+ "date" : null
+ }
+ } ],
+ "pageNumber" : 0,
+ "pageSize" : 100,
+ "pageCount" : 1
+}
diff --git a/modules/moneta-api/src/test/resources/moneta/live/pending-card.json b/modules/moneta-api/src/test/resources/moneta/live/pending-card.json
new file mode 100644
index 0000000..27faaa7
--- /dev/null
+++ b/modules/moneta-api/src/test/resources/moneta/live/pending-card.json
@@ -0,0 +1,62 @@
+{
+ "transactions": [
+ {
+ "amount": {
+ "currency": "CZK",
+ "value": 30.0
+ },
+ "bankTransactionCode": {
+ "proprietary": {
+ "code": "30000301000",
+ "issuer": "CNB"
+ }
+ },
+ "bookingDate": {
+ "date": null
+ },
+ "creditDebitIndicator": "DBIT",
+ "enteredDate": {
+ "date": "2026-11-07"
+ },
+ "entryDetails": {
+ "transactionDetails": {
+ "references": {
+ "clearingSystemReference": "BLOCKED_PAYMENT_CARD",
+ "transactionDescription": ""
+ },
+ "relatedParties": {
+ "creditor": {
+ "name": "PAYFAC *MICHAL LANDSMAN PRAHA CZ"
+ },
+ "creditorAccount": {
+ "identification": {}
+ },
+ "debtorAccount": {
+ "identification": {
+ "other": {
+ "identification": "1337/0600"
+ }
+ }
+ }
+ },
+ "remittanceInformation": {
+ "structured": {
+ "creditorReferenceInformation": {
+ "reference": ""
+ }
+ }
+ }
+ }
+ },
+ "entryReference": "2600000000000000001",
+ "reversalIndicator": false,
+ "status": "PDNG",
+ "valueDate": {
+ "date": null
+ }
+ }
+ ],
+ "pageNumber": 0,
+ "pageSize": 100,
+ "pageCount": 1
+}