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 +}