From 60913b54f30a827506464a326879c3fe3d4a6eff Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Sat, 7 Nov 2026 19:15:28 +0100 Subject: [PATCH 01/10] be: tell a card payment from a transfer, recorded from a live pending one --- .docs/moneta-api.md | 25 ++++++++ .docs/usage.md | 3 + .../cafe/payment/PaymentRecordingTest.java | 4 ++ .../cafe/payment/PaymentWritesTest.java | 2 + .../java/cz/insuit/moneta/ReferencesJson.java | 10 +++ .../java/cz/insuit/moneta/Transaction.java | 10 ++- .../insuit/moneta/TransactionDetailsJson.java | 1 + .../cz/insuit/moneta/TransactionJson.java | 12 ++++ .../cz/insuit/moneta/MonetaClientTest.java | 22 +++++++ .../src/test/resources/moneta/README.md | 2 +- .../resources/moneta/live/pending-card.json | 62 +++++++++++++++++++ 11 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java create mode 100644 modules/moneta-api/src/test/resources/moneta/live/pending-card.json diff --git a/.docs/moneta-api.md b/.docs/moneta-api.md index 6ef3dbf..bc8f99b 100644 --- a/.docs/moneta-api.md +++ b/.docs/moneta-api.md @@ -113,6 +113,31 @@ 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). How it looks once booked is not seen yet. + +| 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 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.** +- **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.** The booked payment may differ in amount, or the hold may lapse without + one; not observed yet. + ### The rest - **A debit is `DBIT` with a positive value**; the direction carries the sign. diff --git a/.docs/usage.md b/.docs/usage.md index b6d55c3..3943ff4 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -30,6 +30,9 @@ for (var account : moneta.accounts(token)) { `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. +- **`card()`** is true for a card payment: a debit whose `counterpartyName()` is the merchant descriptor (name, + city and country at fixed widths) and which has no `counterpartyAccount()`. Seen pending only so far, and a + pending card payment is a hold the booked one may not match. - **`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. 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..8822cc7 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, + false, "{}"); assertEquals( @@ -109,6 +110,7 @@ void aReversalIsNotRecorded() { null, "0 4040404040/0800", true, + false, "{}"); 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, + false, "{}"); } @@ -152,6 +155,7 @@ private static Transaction booked(String reference, @Nullable String symbol, Str "Novak, Jan", account, false, + false, "{}"); } 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..5fd0570 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, + false, "{}"); } @@ -126,6 +127,7 @@ private static Transaction booked(String reference) { "Novak, Jan", "0 4040404040/0800", false, + false, "{}"); } } 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..bbbcb7f --- /dev/null +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java @@ -0,0 +1,10 @@ +package cz.insuit.moneta; + +import org.jspecify.annotations.Nullable; + +/** + * {@code clearingSystemReference} names how the movement was made: {@code PAYMENT_ORDER_DOMESTIC} for a transfer, + * {@code BLOCKED_PAYMENT_ORDER_DOMESTIC} while it is pending, {@code BLOCKED_PAYMENT_CARD} for a pending card + * payment (live, 2026-11-07). + */ +record ReferencesJson(@Nullable String clearingSystemReference) {} 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..69f8b22 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 card whether a card made it. A card payment is a debit whose counterparty name is the card network's + * merchant descriptor, padded to fixed widths, and which has no counterparty account. Seen pending only so far + * (live, 2026-11-07); a pending card payment is a hold, which the booked one may not match * @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, + boolean card, 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, card=%s]" .formatted( entryReference, status, @@ -56,7 +60,8 @@ public String toString() { bookingDate, valueDate, symbols, - reversed); + reversed, + card); } /** Which way the money moved; {@link #amount()} already carries it as the sign. */ @@ -95,6 +100,7 @@ static Transaction from(TransactionJson json, String raw) { json.counterpartyName(direction == Direction.CREDIT), json.counterpartyAccount(direction == Direction.CREDIT), Boolean.TRUE.equals(json.reversalIndicator()), + json.card(), 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..cd1bf3d 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 @@ -58,6 +58,18 @@ String counterpartyAccount(boolean credit) { .orElse(null); } + /** + * Whether a card made the movement. Seen only pending so far, as {@code BLOCKED_PAYMENT_CARD}; the booked value + * is assumed to keep the {@code PAYMENT_CARD} ending, as a transfer's keeps {@code PAYMENT_ORDER_DOMESTIC}. + */ + boolean card() { + return details() + .map(TransactionDetailsJson::references) + .map(ReferencesJson::clearingSystemReference) + .filter(reference -> reference.endsWith("PAYMENT_CARD")) + .isPresent(); + } + private Optional details() { return Optional.ofNullable(entryDetails).map(EntryDetailsJson::transactionDetails); } 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..3888efc 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,28 @@ 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(); + + assertTrue(card.card()); + 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 aTransferIsNotACardPayment() { + answer(TRANSACTIONS, 200, fixture("live/booked.json")); + assertTrue(client.transactions(TOKEN, "acc-1", FROM, TO).stream().noneMatch(Transaction::card)); + } + @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..e9fc936 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 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 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/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 +} From d95068deeba66966cd225560498e90cd51271bbe Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 14:48:48 +0100 Subject: [PATCH 02/10] be: name a booked card payment's merchant from its description, recorded once the hold was booked --- .docs/moneta-api.md | 33 ++++++++-- .docs/usage.md | 6 +- .../java/cz/insuit/moneta/ReferencesJson.java | 7 ++- .../java/cz/insuit/moneta/Transaction.java | 6 +- .../cz/insuit/moneta/TransactionJson.java | 19 +++--- .../cz/insuit/moneta/MonetaClientTest.java | 16 +++++ .../src/test/resources/moneta/README.md | 2 +- .../resources/moneta/live/booked-card.json | 63 +++++++++++++++++++ 8 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 modules/moneta-api/src/test/resources/moneta/live/booked-card.json diff --git a/.docs/moneta-api.md b/.docs/moneta-api.md index bc8f99b..1174227 100644 --- a/.docs/moneta-api.md +++ b/.docs/moneta-api.md @@ -117,7 +117,9 @@ reference" is `entryReference`), and shows the same "Okamžitá platba" while pe 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). How it looks once booked is not seen yet. +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 | |-------|----------------------|----------------------------------| @@ -129,14 +131,33 @@ reference and merchant replaced). How it looks once booked is not seen yet. | symbols (`reference`) | `""` | `"VS:"` | | `amount.value` | `30.0` | `1` | -- **The 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 +- **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.** +- **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.** The booked payment may differ in amount, or the hold may lapse without - one; not observed yet. +- **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 diff --git a/.docs/usage.md b/.docs/usage.md index 3943ff4..1781269 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -30,9 +30,9 @@ for (var account : moneta.accounts(token)) { `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. -- **`card()`** is true for a card payment: a debit whose `counterpartyName()` is the merchant descriptor (name, - city and country at fixed widths) and which has no `counterpartyAccount()`. Seen pending only so far, and a - pending card payment is a hold the booked one may not match. +- **`card()`** is true for a card payment: a debit whose `counterpartyName()` is the merchant and which has no + `counterpartyAccount()`. The merchant text differs between the pending hold and the booked payment, so match + a card payment by amount and date, not by name. - **`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. 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 index bbbcb7f..1999ce5 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java @@ -5,6 +5,9 @@ /** * {@code clearingSystemReference} names how the movement was made: {@code PAYMENT_ORDER_DOMESTIC} for a transfer, * {@code BLOCKED_PAYMENT_ORDER_DOMESTIC} while it is pending, {@code BLOCKED_PAYMENT_CARD} for a pending card - * payment (live, 2026-11-07). + * payment (live, 2026-11-07) and {@code PAYMENT_CARD} once it is booked (live, 2026-11-10). + * {@code transactionDescription} carries a booked card payment's merchant, which the pending one sends as the + * creditor. */ -record ReferencesJson(@Nullable String clearingSystemReference) {} +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 69f8b22..5263874 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,9 +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 card whether a card made it. A card payment is a debit whose counterparty name is the card network's - * merchant descriptor, padded to fixed widths, and which has no counterparty account. Seen pending only so far - * (live, 2026-11-07); a pending card payment is a hold, which the booked one may not match + * @param card whether a card made it. 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 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 cd1bf3d..ed830b2 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,17 @@ 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(() -> card() ? references().map(ReferencesJson::transactionDescription) : Optional.empty()) + .filter(name -> !name.isBlank()) .orElse(null); } @@ -58,13 +63,9 @@ String counterpartyAccount(boolean credit) { .orElse(null); } - /** - * Whether a card made the movement. Seen only pending so far, as {@code BLOCKED_PAYMENT_CARD}; the booked value - * is assumed to keep the {@code PAYMENT_CARD} ending, as a transfer's keeps {@code PAYMENT_ORDER_DOMESTIC}. - */ + /** Whether a card made the movement: {@code BLOCKED_PAYMENT_CARD} pending, {@code PAYMENT_CARD} booked. */ boolean card() { - return details() - .map(TransactionDetailsJson::references) + return references() .map(ReferencesJson::clearingSystemReference) .filter(reference -> reference.endsWith("PAYMENT_CARD")) .isPresent(); @@ -74,6 +75,10 @@ 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/test/java/cz/insuit/moneta/MonetaClientTest.java b/modules/moneta-api/src/test/java/cz/insuit/moneta/MonetaClientTest.java index 3888efc..ce9d717 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 @@ -375,6 +375,22 @@ void aPendingCardPaymentIsADebitToTheMerchantWithNoAccount() { 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(); + + assertTrue(card.card()); + 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 aTransferIsNotACardPayment() { answer(TRANSACTIONS, 200, fixture("live/booked.json")); diff --git a/modules/moneta-api/src/test/resources/moneta/README.md b/modules/moneta-api/src/test/resources/moneta/README.md index e9fc936..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 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 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 +} From 11de1d31651e48e83b7cddda40bd4fcb349325c6 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 14:59:27 +0100 Subject: [PATCH 03/10] docs: say card payments are told apart in the transaction list, not read from their own endpoint --- .docs/plan/roadmap.md | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.docs/plan/roadmap.md b/.docs/plan/roadmap.md index 5ec06ee..d8ddb55 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 `card()`. 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/README.md b/README.md index 33e1dd6..5f1d914 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, `card()`; not their own endpoint | | Balances | ✅ | ✅ | | Pending card transactions | ✅ | ⬜ | | Electronic statements | ✅ | ⬜ | From bd2c0f45702d546faa275d065cc99b2944ce0548 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 15:00:19 +0100 Subject: [PATCH 04/10] docs: describe each references field on its own @param --- .../src/main/java/cz/insuit/moneta/ReferencesJson.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 1999ce5..866a8a6 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/ReferencesJson.java @@ -3,11 +3,11 @@ import org.jspecify.annotations.Nullable; /** - * {@code clearingSystemReference} names how the movement was made: {@code PAYMENT_ORDER_DOMESTIC} for a transfer, - * {@code BLOCKED_PAYMENT_ORDER_DOMESTIC} while it is pending, {@code BLOCKED_PAYMENT_CARD} for a pending card - * payment (live, 2026-11-07) and {@code PAYMENT_CARD} once it is booked (live, 2026-11-10). - * {@code transactionDescription} carries a booked card payment's merchant, which the pending one sends as the - * creditor. + * 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) {} From 491d7420934aaadf66ec43e7d7806c832d057848 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 15:06:26 +0100 Subject: [PATCH 05/10] be: filter transactions by chaining, transfers or cards, in or out, booked or pending --- .docs/plan/roadmap.md | 2 +- .docs/usage.md | 14 ++- README.md | 2 +- .../cafe/payment/PaymentRecordingTest.java | 8 +- .../cafe/payment/PaymentWritesTest.java | 4 +- .../java/cz/insuit/moneta/MonetaClient.java | 11 +- .../java/cz/insuit/moneta/Transaction.java | 28 +++-- .../cz/insuit/moneta/TransactionJson.java | 19 ++- .../cz/insuit/moneta/TransactionQuery.java | 115 ++++++++++++++++++ .../cz/insuit/moneta/MonetaClientTest.java | 46 ++++++- 10 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java diff --git a/.docs/plan/roadmap.md b/.docs/plan/roadmap.md index d8ddb55..4912dce 100644 --- a/.docs/plan/roadmap.md +++ b/.docs/plan/roadmap.md @@ -8,7 +8,7 @@ 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()`, and tells a card payment from a transfer with `card()`. Checked against a live business account + `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 diff --git a/.docs/usage.md b/.docs/usage.md index 1781269..d0d945b 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -30,8 +30,18 @@ for (var account : moneta.accounts(token)) { `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. -- **`card()`** is true for a card payment: a debit whose `counterpartyName()` is the merchant and which has no - `counterpartyAccount()`. The merchant text differs between the pending hold and the booked payment, so match +- **`method()`** says how the money moved: `TRANSFER`, `CARD`, or `OTHER` for anything not mapped yet (fees, + interest, ATM withdrawals: [#66](https://github.com/landsman/moneta-api-java/issues/66)). + Filter by it, and by direction and status, by chaining: + + ```java + moneta.transactions(token, accountId).between(from, to).transfers().outgoing().list(); + moneta.transactions(token, accountId).between(from, to).cards().booked().list(); + ``` + + Only `between` is required; `incoming()`, `outgoing()`, `booked()`, `pending()` and `method(…)` are optional. + MONETA filters by date only, so every movement in the range is read and the rest dropped here. A card payment is a debit whose `counterpartyName()` is the merchant and which has + no `counterpartyAccount()`; the merchant text differs between the pending hold and the booked payment, so match a card payment by amount and date, not by name. - **`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 diff --git a/README.md b/README.md index 5f1d914..7339d7a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ client implements: |---|:---:|:---:| | Accounts the token sees | ✅ | ✅ | | Transactions, pending and booked | ✅ | ✅ | -| Card transactions | ✅ | in the transaction list, `card()`; not their own endpoint | +| 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 8822cc7..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,7 +87,7 @@ void theBookedVersionBooksThePendingRowWhateverDayItCarries() { "Novak, Jan", "0 4040404040/0800", false, - false, + Transaction.Method.TRANSFER, "{}"); assertEquals( @@ -110,7 +110,7 @@ void aReversalIsNotRecorded() { null, "0 4040404040/0800", true, - false, + Transaction.Method.TRANSFER, "{}"); assertEquals(List.of(), PaymentRecording.steps(List.of(reversal), Set.of(), List.of())); @@ -137,7 +137,7 @@ private static Transaction pending(String reference, @Nullable String symbol, St null, account, false, - false, + Transaction.Method.TRANSFER, "{}"); } @@ -155,7 +155,7 @@ private static Transaction booked(String reference, @Nullable String symbol, Str "Novak, Jan", account, false, - 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 5fd0570..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,7 +109,7 @@ private static Transaction pending(String reference) { null, "4040404040/0800", false, - false, + Transaction.Method.TRANSFER, "{}"); } @@ -127,7 +127,7 @@ private static Transaction booked(String reference) { "Novak, Jan", "0 4040404040/0800", false, - false, + Transaction.Method.TRANSFER, "{}"); } } 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..b6a9150 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,15 @@ public List incoming(AccessToken token, String accountId, LocalDate .toList(); } + /** + * A query over the account's movements, narrowed by chaining and read by {@link TransactionQuery#list()}: + * {@code transactions(token, accountId).between(from, to).transfers().outgoing().list()}. Nothing is read + * until {@code list()}. + */ + public TransactionQuery transactions(AccessToken token, String accountId) { + return new TransactionQuery(this, token, accountId); + } + /** * 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/Transaction.java b/modules/moneta-api/src/main/java/cz/insuit/moneta/Transaction.java index 5263874..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,9 +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 card whether a card made it. 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 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 @@ -40,7 +40,7 @@ public record Transaction( @Nullable String counterpartyName, @Nullable String counterpartyAccount, boolean reversed, - boolean card, + Method method, String raw) { /** Whether the movement is booked and will not change. */ @@ -50,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, card=%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, @@ -61,7 +61,7 @@ public String toString() { valueDate, symbols, reversed, - card); + method); } /** Which way the money moved; {@link #amount()} already carries it as the sign. */ @@ -70,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, @@ -100,7 +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.card(), + json.method(), raw); } 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 ed830b2..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 @@ -47,7 +47,9 @@ String counterpartyName(boolean credit) { return parties() .map(parties -> credit ? parties.debtor() : parties.creditor()) .map(PartyJson::name) - .or(() -> card() ? references().map(ReferencesJson::transactionDescription) : Optional.empty()) + .or(() -> method() == Transaction.Method.CARD + ? references().map(ReferencesJson::transactionDescription) + : Optional.empty()) .filter(name -> !name.isBlank()) .orElse(null); } @@ -63,12 +65,17 @@ String counterpartyAccount(boolean credit) { .orElse(null); } - /** Whether a card made the movement: {@code BLOCKED_PAYMENT_CARD} pending, {@code PAYMENT_CARD} booked. */ - boolean card() { - return references() + /** How the money moved; a pending movement's reference carries a {@code BLOCKED_} prefix. */ + Transaction.Method method() { + var reference = references() .map(ReferencesJson::clearingSystemReference) - .filter(reference -> reference.endsWith("PAYMENT_CARD")) - .isPresent(); + .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() { 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..832c904 --- /dev/null +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java @@ -0,0 +1,115 @@ +package cz.insuit.moneta; + +import static java.util.Objects.requireNonNull; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CancellationException; +import org.jspecify.annotations.Nullable; + +/** + * The movements on one account, narrowed step by step and read by {@link #list()}: + * + *
{@code
+ * moneta.transactions(token, accountId).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 String 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, String 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 + * @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, 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, + String 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 ce9d717..50cb453 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 @@ -366,7 +366,7 @@ void aPendingCardPaymentIsADebitToTheMerchantWithNoAccount() { var card = client.transactions(TOKEN, "acc-1", FROM, TO).getFirst(); - assertTrue(card.card()); + 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()); @@ -382,7 +382,7 @@ void aBookedCardPaymentKeepsTheHoldsAmountAndNamesTheMerchantFromItsDescription( var card = client.transactions(TOKEN, "acc-1", FROM, TO).getFirst(); - assertTrue(card.card()); + 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()); @@ -392,9 +392,47 @@ void aBookedCardPaymentKeepsTheHoldsAmountAndNamesTheMerchantFromItsDescription( } @Test - void aTransferIsNotACardPayment() { + void bookedTransfersAreTransfers() { answer(TRANSACTIONS, 200, fixture("live/booked.json")); - assertTrue(client.transactions(TOKEN, "acc-1", FROM, TO).stream().noneMatch(Transaction::card)); + 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 aQueryWithoutDatesIsRefusedBeforeAnyRequest() { + assertThrows( + IllegalStateException.class, + () -> client.transactions(TOKEN, "acc-1").cards().list()); + assertTrue(requests.isEmpty()); } @Test From 4346e07f07fb54fc5b18bdef064460f929c11ca0 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 15:19:30 +0100 Subject: [PATCH 06/10] docs: give filtering its own section, and say Method gains constants as they are seen --- .docs/usage.md | 47 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index d0d945b..44420cf 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -31,21 +31,14 @@ for (var account : moneta.accounts(token)) { - **`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. - **`method()`** says how the money moved: `TRANSFER`, `CARD`, or `OTHER` for anything not mapped yet (fees, - interest, ATM withdrawals: [#66](https://github.com/landsman/moneta-api-java/issues/66)). - Filter by it, and by direction and status, by chaining: - - ```java - moneta.transactions(token, accountId).between(from, to).transfers().outgoing().list(); - moneta.transactions(token, accountId).between(from, to).cards().booked().list(); - ``` - - Only `between` is required; `incoming()`, `outgoing()`, `booked()`, `pending()` and `method(…)` are optional. - MONETA filters by date only, so every movement in the range is read and the rest dropped here. A card payment is a debit whose `counterpartyName()` is the merchant and which has - no `counterpartyAccount()`; the merchant text differs between the pending hold and the booked payment, so match - a card payment by amount and date, not by name. + interest, ATM withdrawals: [#66](https://github.com/landsman/moneta-api-java/issues/66)). Constants will be + added as they are seen live, so a `switch` or a Kotlin `when` over it keeps a `default` or an `else`. A card + payment is a debit whose `counterpartyName()` is the merchant and which has no `counterpartyAccount()`; the + merchant text differs between the pending hold and the booked payment, so match one by amount and date, not by + name. - **`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. + makes; the same as `.incoming()` in [a query](#filtering). - **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. @@ -77,6 +70,34 @@ for (var account : moneta.accounts(token)) { --- + +## Filtering + +`transactions(token, accountId)` starts a query, narrowed by chaining and read by `list()`: + +```java +var november = moneta.transactions(token, accountId).between(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 30)); + +november.transfers().outgoing().list(); // transfers that went out +november.cards().booked().list(); // card payments, holds left out +november.incoming().list(); // everything that came in +``` + +| Step | Keeps | +|------|-------| +| `between(from, to)` | the date range, both days inclusive; **required** | +| `transfers()`, `cards()`, `method(…)` | movements made that way | +| `incoming()`, `outgoing()` | money in, money out | +| `booked()`, `pending()` | booked movements, pending ones | + +- **Every step but `between` is optional**, and the steps combine: each one narrows the last. A later step of + the same kind replaces the earlier one, so `incoming().outgoing()` is outgoing. +- **A query is immutable**: each step returns a new one, so one base query can be narrowed two ways, as above. +- **Nothing is read until `list()`**, and `list()` without `between` throws `IllegalStateException` before any + request. +- **MONETA filters by date only**, so `list()` reads every movement in the range, all pages, and drops the rest + in the client. Narrowing does not make the read cheaper, a shorter range does. + ## Optional settings `MonetaClient.builder()` sets what differs from production: `baseUri(…)` for the sandbox, `requestTimeout(…)`, and From ed56d22be79080858a60f7e3b42a0307960a26cd Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 16:00:08 +0100 Subject: [PATCH 07/10] be: let a query read the only account a token opens, so a one-account app passes no id --- .docs/usage.md | 54 +++++++++++++++++-- .../java/cz/insuit/moneta/MonetaClient.java | 12 ++++- .../cz/insuit/moneta/TransactionQuery.java | 22 +++++--- .../cz/insuit/moneta/MonetaClientTest.java | 22 ++++++++ 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index 44420cf..4b8a868 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -73,20 +73,66 @@ for (var account : moneta.accounts(token)) { ## Filtering -`transactions(token, accountId)` starts a query, narrowed by chaining and read by `list()`: +`transactions(token, accountId)`, or `transactions(token)` for a token with one account, starts a query, narrowed +by chaining and read by `list()`. The token says who is asking, the merchant whose Internet Banka issued it; the +account id says which of that merchant's accounts, since one token can open several. The client stores neither, +so one `MonetaClient` serves every merchant. + +One account, the usual shop: leave the id out and the query reads the only account the token opens. ```java -var november = moneta.transactions(token, accountId).between(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 30)); +var moneta = new MonetaClient(); +var token = new AccessToken(System.getenv("MONETA_TOKEN")); // with the Spring Boot starter: moneta.token, injected + +var november = moneta.transactions(token).between(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 30)); november.transfers().outgoing().list(); // transfers that went out -november.cards().booked().list(); // card payments, holds left out +november.cards().booked().list(); // paid by the account's card, holds left out november.incoming().list(); // everything that came in ``` +Each `list()` then asks for the accounts first, one request more, and throws `IllegalStateException` when the +token opens other than exactly one. A poller reading every few seconds names the account instead: +`moneta.transactions(token, accountId)`, the id from `moneta.accounts(token)` or your config. + +Every account the token opens, here what its cards spent last month, summed per account: + +```java +var lastMonth = YearMonth.now(ZoneId.of("Europe/Prague")).minusMonths(1); + +for (var account : moneta.accounts(token)) { + var spent = moneta.transactions(token, account.id()) + .between(lastMonth.atDay(1), lastMonth.atEndOfMonth()) + .cards() + .booked() + .list(); + var total = spent.stream().map(t -> t.amount().amount()).reduce(BigDecimal.ZERO, BigDecimal::add); + System.out.println(account.id() + ": " + spent.size() + " card payments, " + total); // negative: money out +} +``` + +Several merchants, one client, each with the token you keep for them (a platform, an accountant's tool): + +```java +record Merchant(String name, AccessToken token, String accountId) {} + +var today = LocalDate.now(ZoneId.of("Europe/Prague")); +for (var merchant : merchants) { + var holds = moneta.transactions(merchant.token(), merchant.accountId()) + .between(today.minusDays(7), today) + .cards() + .pending() + .list(); + if (!holds.isEmpty()) { + System.out.println(merchant.name() + ": " + holds.size() + " card payments still on hold"); + } +} +``` + | Step | Keeps | |------|-------| | `between(from, to)` | the date range, both days inclusive; **required** | -| `transfers()`, `cards()`, `method(…)` | movements made that way | +| `transfers()`, `cards()`, `method(…)` | movements made that way; `cards()` is the account's own card paying, while a customer's card payment usually reaches a merchant from the acquirer as a transfer | | `incoming()`, `outgoing()` | money in, money out | | `booked()`, `pending()` | booked movements, pending ones | 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 b6a9150..88d2e68 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 @@ -173,7 +173,17 @@ public List incoming(AccessToken token, String accountId, LocalDate * until {@code list()}. */ public TransactionQuery transactions(AccessToken token, String accountId) { - return new TransactionQuery(this, token, accountId); + return new TransactionQuery(this, token, requireNonNull(accountId, "accountId")); + } + + /** + * {@link #transactions(AccessToken, String)} on the only account the token opens, for an application with one: + * {@code transactions(token).between(from, to).cards().list()}. {@code list()} asks for the accounts first, one + * request more each time, and throws {@link IllegalStateException} unless there is exactly one. A poller reading + * every few seconds keeps the id instead. + */ + public TransactionQuery transactions(AccessToken token) { + return new TransactionQuery(this, token, null); } /** 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 index 832c904..253817c 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java @@ -22,14 +22,14 @@ public final class TransactionQuery { private final MonetaClient client; private final AccessToken token; - private final String accountId; + private final @Nullable String 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, String accountId) { + TransactionQuery(MonetaClient client, AccessToken token, @Nullable String accountId) { this(client, token, accountId, null, null, null, null, null); } @@ -77,7 +77,8 @@ public TransactionQuery pending() { /** * Reads the range, all pages, and keeps what every step asked for. * - * @throws IllegalStateException when {@link #between} was not called + * @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 @@ -87,17 +88,26 @@ 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, from, to).stream() + return client.transactions(token, accountId != null ? accountId : onlyAccount(), 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 String onlyAccount() { + var accounts = client.accounts(token); + if (accounts.size() != 1) { + throw new IllegalStateException( + "the token opens " + accounts.size() + " accounts, so name one: transactions(token, accountId)"); + } + return accounts.getFirst().id(); + } + private TransactionQuery( MonetaClient client, AccessToken token, - String accountId, + @Nullable String accountId, @Nullable LocalDate from, @Nullable LocalDate to, Transaction.@Nullable Method method, @@ -105,7 +115,7 @@ private TransactionQuery( Transaction.@Nullable Status status) { this.client = requireNonNull(client); this.token = requireNonNull(token); - this.accountId = requireNonNull(accountId); + this.accountId = accountId; this.from = from; this.to = to; this.method = method; 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 50cb453..2e40cca 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 @@ -427,6 +427,28 @@ void aQueryFindsTheCardPayment() { 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.transactions(TOKEN).between(FROM, TO).cards().list().size()); + } + + @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.transactions(TOKEN).between(FROM, TO).list()); + assertTrue(String.valueOf(refused.getMessage()).contains("opens 2 accounts"), refused.getMessage()); + } + @Test void aQueryWithoutDatesIsRefusedBeforeAnyRequest() { assertThrows( From 0f29cc4b31d53210a43014794b8871d8196066b8 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 16:27:26 +0100 Subject: [PATCH 08/10] docs: show several account holders on one client, personal accounts included, not only merchants --- .docs/usage.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index 4b8a868..0e7c73e 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -13,7 +13,7 @@ for (var account : moneta.accounts(token)) { } ``` -- **One client for many merchants.** The token goes with each call as an `AccessToken` and is never stored, +- **One client for many account holders.** 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 @@ -74,11 +74,11 @@ for (var account : moneta.accounts(token)) { ## Filtering `transactions(token, accountId)`, or `transactions(token)` for a token with one account, starts a query, narrowed -by chaining and read by `list()`. The token says who is asking, the merchant whose Internet Banka issued it; the -account id says which of that merchant's accounts, since one token can open several. The client stores neither, -so one `MonetaClient` serves every merchant. +by chaining and read by `list()`. The token says who is asking, the account holder whose Internet Banka issued it; +the account id says which of their accounts, since one token can open several. The client stores neither, so one +`MonetaClient` serves every holder. -One account, the usual shop: leave the id out and the query reads the only account the token opens. +One account: leave the id out and the query reads the only account the token opens. ```java var moneta = new MonetaClient(); @@ -111,28 +111,28 @@ for (var account : moneta.accounts(token)) { } ``` -Several merchants, one client, each with the token you keep for them (a platform, an accountant's tool): +Several account holders on one client, a company, a sole trader or a person, each with their own token: ```java -record Merchant(String name, AccessToken token, String accountId) {} +record Holder(String name, AccessToken token, String accountId) {} var today = LocalDate.now(ZoneId.of("Europe/Prague")); -for (var merchant : merchants) { - var holds = moneta.transactions(merchant.token(), merchant.accountId()) +holders.forEach(holder -> { + var holds = moneta.transactions(holder.token(), holder.accountId()) .between(today.minusDays(7), today) .cards() .pending() .list(); if (!holds.isEmpty()) { - System.out.println(merchant.name() + ": " + holds.size() + " card payments still on hold"); + System.out.println(holder.name() + ": " + holds.size() + " card payments still on hold"); } -} +}); ``` | Step | Keeps | |------|-------| | `between(from, to)` | the date range, both days inclusive; **required** | -| `transfers()`, `cards()`, `method(…)` | movements made that way; `cards()` is the account's own card paying, while a customer's card payment usually reaches a merchant from the acquirer as a transfer | +| `transfers()`, `cards()`, `method(…)` | movements made that way; `cards()` is the account's own card paying, while a card payment someone makes to the account usually arrives from the card acquirer as a transfer | | `incoming()`, `outgoing()` | money in, money out | | `booked()`, `pending()` | booked movements, pending ones | From 170dea6a01eae13720d7e08f10ea97d80c168cd6 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 16:32:12 +0100 Subject: [PATCH 09/10] be: bind a token once with moneta.with(token), remembering the only account's id --- .../java/cz/insuit/moneta/AccountHolder.java | 73 +++++++++++++++++++ .../java/cz/insuit/moneta/MonetaClient.java | 30 +++++--- .../cz/insuit/moneta/TransactionQuery.java | 22 ++---- .../cz/insuit/moneta/MonetaClientTest.java | 26 ++++++- 4 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 modules/moneta-api/src/main/java/cz/insuit/moneta/AccountHolder.java 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 88d2e68..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 @@ -168,22 +168,28 @@ public List incoming(AccessToken token, String accountId, LocalDate } /** - * A query over the account's movements, narrowed by chaining and read by {@link TransactionQuery#list()}: - * {@code transactions(token, accountId).between(from, to).transfers().outgoing().list()}. Nothing is read - * until {@code list()}. + * 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) { - return new TransactionQuery(this, token, requireNonNull(accountId, "accountId")); + requireNonNull(accountId, "accountId"); + return new TransactionQuery(this, token, () -> accountId); } - /** - * {@link #transactions(AccessToken, String)} on the only account the token opens, for an application with one: - * {@code transactions(token).between(from, to).cards().list()}. {@code list()} asks for the accounts first, one - * request more each time, and throws {@link IllegalStateException} unless there is exactly one. A poller reading - * every few seconds keeps the id instead. - */ - public TransactionQuery transactions(AccessToken token) { - return new TransactionQuery(this, token, null); + /** 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(); } /** 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 index 253817c..82417a7 100644 --- a/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java +++ b/modules/moneta-api/src/main/java/cz/insuit/moneta/TransactionQuery.java @@ -5,13 +5,14 @@ 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.transactions(token, accountId).between(from, to).transfers().outgoing().list();
+ * 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 @@ -22,14 +23,14 @@ public final class TransactionQuery { private final MonetaClient client; private final AccessToken token; - private final @Nullable String accountId; + 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, @Nullable String accountId) { + TransactionQuery(MonetaClient client, AccessToken token, Supplier accountId) { this(client, token, accountId, null, null, null, null, null); } @@ -88,26 +89,17 @@ 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 != null ? accountId : onlyAccount(), from, to).stream() + 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 String onlyAccount() { - var accounts = client.accounts(token); - if (accounts.size() != 1) { - throw new IllegalStateException( - "the token opens " + accounts.size() + " accounts, so name one: transactions(token, accountId)"); - } - return accounts.getFirst().id(); - } - private TransactionQuery( MonetaClient client, AccessToken token, - @Nullable String accountId, + Supplier accountId, @Nullable LocalDate from, @Nullable LocalDate to, Transaction.@Nullable Method method, @@ -115,7 +107,7 @@ private TransactionQuery( Transaction.@Nullable Status status) { this.client = requireNonNull(client); this.token = requireNonNull(token); - this.accountId = accountId; + this.accountId = requireNonNull(accountId); this.from = from; this.to = to; this.method = method; 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 2e40cca..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 @@ -435,7 +435,29 @@ void aQueryWithoutAnAccountReadsTheOnlyOne() { assertEquals( 1, - client.transactions(TOKEN).between(FROM, TO).cards().list().size()); + 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 @@ -445,7 +467,7 @@ void aQueryWithoutAnAccountRefusesATokenWithTwo() { var refused = assertThrows( IllegalStateException.class, - () -> client.transactions(TOKEN).between(FROM, TO).list()); + () -> client.with(TOKEN).transactions().between(FROM, TO).list()); assertTrue(String.valueOf(refused.getMessage()).contains("opens 2 accounts"), refused.getMessage()); } From d617484fb5fd71164d3eb3fd63eae6ff30b73c9c Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 16:32:13 +0100 Subject: [PATCH 10/10] docs: cut the usage guide to what a caller needs, with kotlin beside each java example --- .docs/usage.md | 225 +++++++++++++++++++++---------------------------- 1 file changed, 97 insertions(+), 128 deletions(-) diff --git a/.docs/usage.md b/.docs/usage.md index 0e7c73e..af0360b 100644 --- a/.docs/usage.md +++ b/.docs/usage.md @@ -1,152 +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 ``` -- **One client for many account holders.** 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. -- **`method()`** says how the money moved: `TRANSFER`, `CARD`, or `OTHER` for anything not mapped yet (fees, - interest, ATM withdrawals: [#66](https://github.com/landsman/moneta-api-java/issues/66)). Constants will be - added as they are seen live, so a `switch` or a Kotlin `when` over it keeps a `default` or an `else`. A card - payment is a debit whose `counterpartyName()` is the merchant and which has no `counterpartyAccount()`; the - merchant text differs between the pending hold and the booked payment, so match one by amount and date, not by - name. -- **`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; the same as `.incoming()` in [a query](#filtering). -- **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. - ---- +
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 -`transactions(token, accountId)`, or `transactions(token)` for a token with one account, starts a query, narrowed -by chaining and read by `list()`. The token says who is asking, the account holder whose Internet Banka issued it; -the account id says which of their accounts, since one token can open several. The client stores neither, so one -`MonetaClient` serves every holder. +| 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. -One account: leave the id out and the query reads the only account the token opens. +## 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 -var moneta = new MonetaClient(); -var token = new AccessToken(System.getenv("MONETA_TOKEN")); // with the Spring Boot starter: moneta.token, injected +for (var account : me.accounts()) { + me.transactions(account.id()).between(from, to).cards().list(); +} +``` -var november = moneta.transactions(token).between(LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 30)); +
Kotlin -november.transfers().outgoing().list(); // transfers that went out -november.cards().booked().list(); // paid by the account's card, holds left out -november.incoming().list(); // everything that came in +```kotlin +for (account in me.accounts()) { + me.transactions(account.id).between(from, to).cards().list() +} ``` -Each `list()` then asks for the accounts first, one request more, and throws `IllegalStateException` when the -token opens other than exactly one. A poller reading every few seconds names the account instead: -`moneta.transactions(token, accountId)`, the id from `moneta.accounts(token)` or your config. +
-Every account the token opens, here what its cards spent last month, summed per account: +Several account holders, each with their own token, share one client: ```java -var lastMonth = YearMonth.now(ZoneId.of("Europe/Prague")).minusMonths(1); - -for (var account : moneta.accounts(token)) { - var spent = moneta.transactions(token, account.id()) - .between(lastMonth.atDay(1), lastMonth.atEndOfMonth()) - .cards() - .booked() - .list(); - var total = spent.stream().map(t -> t.amount().amount()).reduce(BigDecimal.ZERO, BigDecimal::add); - System.out.println(account.id() + ": " + spent.size() + " card payments, " + total); // negative: money out -} +holders.forEach(holder -> moneta.with(holder.token()).transactions().between(from, to).list()); ``` -Several account holders on one client, a company, a sole trader or a person, each with their own token: +
Kotlin -```java -record Holder(String name, AccessToken token, String accountId) {} - -var today = LocalDate.now(ZoneId.of("Europe/Prague")); -holders.forEach(holder -> { - var holds = moneta.transactions(holder.token(), holder.accountId()) - .between(today.minusDays(7), today) - .cards() - .pending() - .list(); - if (!holds.isEmpty()) { - System.out.println(holder.name() + ": " + holds.size() + " card payments still on hold"); - } -}); +```kotlin +holders.forEach { moneta.with(it.token).transactions().between(from, to).list() } ``` -| Step | Keeps | -|------|-------| -| `between(from, to)` | the date range, both days inclusive; **required** | -| `transfers()`, `cards()`, `method(…)` | movements made that way; `cards()` is the account's own card paying, while a card payment someone makes to the account usually arrives from the card acquirer as a transfer | -| `incoming()`, `outgoing()` | money in, money out | -| `booked()`, `pending()` | booked movements, pending ones | - -- **Every step but `between` is optional**, and the steps combine: each one narrows the last. A later step of - the same kind replaces the earlier one, so `incoming().outgoing()` is outgoing. -- **A query is immutable**: each step returns a new one, so one base query can be narrowed two ways, as above. -- **Nothing is read until `list()`**, and `list()` without `between` throws `IllegalStateException` before any - request. -- **MONETA filters by date only**, so `list()` reads every movement in the range, all pages, and drops the rest - in the client. Narrowing does not make the read cheaper, a shorter range does. - -## 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. +
+ +## 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.