From 1f85add1eb9fdb7c93371e30ee5593667ecd0ca6 Mon Sep 17 00:00:00 2001 From: Gabriel Pan Gantes Date: Sun, 4 Oct 2026 23:04:32 +0200 Subject: [PATCH 1/3] feat: support GET /v2/transactions, account balance and new credit card metadata fields - getTransactionsV2 with TransactionsCursorSearchRequest and TransactionsCursorResponse (cursor helpers getNextCursor/hasNext). GET /transactions returns 410 for newer applications, so the v1 methods are now @Deprecated. - getAccountBalance for GET /accounts/{id}/balance. - paymentType, billPostDate and transactionDateTime on TransactionCreditCardMetadata (closes #59). - @NoArgsConstructor/@AllArgsConstructor on @Builder response classes so Jackson-based clients can deserialize them (closes #55). - Bump version to 1.15.0. --- README.md | 20 +++ pom.xml | 2 +- .../ai/pluggy/client/PluggyApiService.java | 31 ++++ .../ai/pluggy/client/request/Options.java | 2 + .../TransactionsCursorSearchRequest.java | 97 ++++++++++ .../ai/pluggy/client/response/Account.java | 4 + .../client/response/AccountBalance.java | 22 +++ .../client/response/AccountsResponse.java | 4 + .../ai/pluggy/client/response/Address.java | 4 + .../pluggy/client/response/AuthResponse.java | 4 + .../ai/pluggy/client/response/BankData.java | 4 + .../java/ai/pluggy/client/response/Bill.java | 4 + .../pluggy/client/response/BillsResponse.java | 4 + .../response/BusinessOtherDocument.java | 4 + .../pluggy/client/response/BusinessParty.java | 4 + .../client/response/CategoriesResponse.java | 4 + .../ai/pluggy/client/response/Category.java | 4 + .../client/response/ConnectTokenResponse.java | 4 + .../ai/pluggy/client/response/Connector.java | 4 + .../client/response/ConnectorIncident.java | 4 + .../client/response/ConnectorsResponse.java | 4 + .../response/CredentialSelectOption.java | 2 + .../CreditCardAccountPaymentType.java | 19 ++ .../ai/pluggy/client/response/CreditData.java | 4 + .../client/response/DeleteItemResponse.java | 4 + .../response/DisaggregatedCreditLimit.java | 4 + .../client/response/DocumentNumber.java | 4 + .../java/ai/pluggy/client/response/Email.java | 4 + .../pluggy/client/response/ErrorDetail.java | 4 + .../ai/pluggy/client/response/Expenses.java | 4 + .../client/response/FinancialCharge.java | 4 + .../response/GeographicCoordinates.java | 4 + .../ai/pluggy/client/response/Health.java | 4 + .../client/response/IdentityRelation.java | 4 + .../client/response/IdentityResponse.java | 4 + .../ai/pluggy/client/response/Investment.java | 4 + .../response/InvestmentInstitution.java | 4 + .../client/response/InvestmentMetadata.java | 4 + .../response/InvestmentTransaction.java | 4 + .../client/response/InvestmentsResponse.java | 4 + .../ai/pluggy/client/response/ItemError.java | 4 + .../client/response/ItemProductState.java | 4 + .../response/ItemProductStepWarning.java | 4 + .../pluggy/client/response/ItemResource.java | 4 + .../pluggy/client/response/ItemResponse.java | 4 + .../client/response/ItemStatusDetail.java | 4 + .../pluggy/client/response/MaritalStatus.java | 4 + .../pluggy/client/response/Nationality.java | 4 + .../client/response/NationalityDocument.java | 4 + .../pluggy/client/response/OtherDocument.java | 4 + .../client/response/OtherNationality.java | 4 + .../ai/pluggy/client/response/Passport.java | 4 + .../client/response/ReservedBalance.java | 4 + .../response/ReservedBalanceAmount.java | 4 + .../response/ReservedBalanceRemuneration.java | 4 + .../TransactionCreditCardMetadata.java | 17 ++ .../response/TransactionsCursorResponse.java | 78 ++++++++ .../unit/BuilderNoArgsConstructorTest.java | 51 ++++++ .../client/unit/TransactionsV2Test.java | 170 ++++++++++++++++++ 59 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ai/pluggy/client/request/TransactionsCursorSearchRequest.java create mode 100644 src/main/java/ai/pluggy/client/response/AccountBalance.java create mode 100644 src/main/java/ai/pluggy/client/response/CreditCardAccountPaymentType.java create mode 100644 src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java create mode 100644 src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java create mode 100644 src/test/java/ai/pluggy/client/unit/TransactionsV2Test.java diff --git a/README.md b/README.md index 7d3b111..e6f1780 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,23 @@ if(connectorsResponse.isSuccessful()) { ErrorResponse errorResponse = pluggyClient.parseError(connectorsResponse) } ``` + +### Transactions (cursor pagination) + +`getTransactions` (`GET /transactions`) is deprecated and returns `410` for newer applications. Use `getTransactionsV2` (`GET /v2/transactions`) and follow the cursor until there is no next page: + +```java +List transactions = new ArrayList<>(); +TransactionsCursorSearchRequest request = new TransactionsCursorSearchRequest().dateFrom("2026-01-01"); +while (true) { + TransactionsCursorResponse page = pluggyClient.service() + .getTransactionsV2(accountId, request) + .execute() + .body(); + transactions.addAll(page.getResults()); + if (!page.hasNext()) { + break; + } + request = new TransactionsCursorSearchRequest().dateFrom("2026-01-01").after(page.getNextCursor()); +} +``` diff --git a/pom.xml b/pom.xml index 004fe82..aaec26f 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ ai.pluggy pluggy-java - 1.14.0 + 1.15.0 jar diff --git a/src/main/java/ai/pluggy/client/PluggyApiService.java b/src/main/java/ai/pluggy/client/PluggyApiService.java index 7db5435..1176aab 100644 --- a/src/main/java/ai/pluggy/client/PluggyApiService.java +++ b/src/main/java/ai/pluggy/client/PluggyApiService.java @@ -6,6 +6,7 @@ import ai.pluggy.client.request.CreateItemRequest; import ai.pluggy.client.request.InvestmentTransactionsSearchRequest; import ai.pluggy.client.request.ItemResourcesSearchRequest; +import ai.pluggy.client.request.TransactionsCursorSearchRequest; import ai.pluggy.client.request.TransactionsSearchRequest; import ai.pluggy.client.request.UpdateItemMfaRequest; import ai.pluggy.client.request.UpdateItemRequest; @@ -76,19 +77,49 @@ Call getItemResources(@Path("id") String itemId, @GET("/accounts/{id}") Call getAccount(@Path("id") String accountId); + /** + * Real-time balance fetched from the institution. Only available for Open Finance connectors; + * accounts on other connectors get an error response. + */ + @GET("/accounts/{id}/balance") + Call getAccountBalance(@Path("id") String accountId); + @GET("/bills") Call getBills(@Query("accountId") String accountId); @GET("/bills/{id}") Call getBill(@Path("id") String billId); + /** + * @deprecated {@code GET /transactions} is deprecated and returns 410 for newer applications. + * Use {@link #getTransactionsV2(String)} (cursor pagination) instead. + */ + @Deprecated @GET("/transactions") Call getTransactions(@Query("accountId") String accountId); + /** + * @deprecated {@code GET /transactions} is deprecated and returns 410 for newer applications. + * Use {@link #getTransactionsV2(String, TransactionsCursorSearchRequest)} (cursor pagination) + * instead. + */ + @Deprecated @GET("/transactions") Call getTransactions(@Query("accountId") String accountId, @QueryMap TransactionsSearchRequest transactionsSearchRequest); + /** + * First page of an account's transactions, cursor-paginated. Fetch the following pages by + * passing {@link TransactionsCursorResponse#getNextCursor()} as + * {@link TransactionsCursorSearchRequest#after(String)} until it returns null. + */ + @GET("/v2/transactions") + Call getTransactionsV2(@Query("accountId") String accountId); + + @GET("/v2/transactions") + Call getTransactionsV2(@Query("accountId") String accountId, + @QueryMap TransactionsCursorSearchRequest transactionsCursorSearchRequest); + @GET("/transactions/{id}") Call getTransaction(@Path("id") String transactionId); diff --git a/src/main/java/ai/pluggy/client/request/Options.java b/src/main/java/ai/pluggy/client/request/Options.java index c7b4a1d..6c30fad 100644 --- a/src/main/java/ai/pluggy/client/request/Options.java +++ b/src/main/java/ai/pluggy/client/request/Options.java @@ -2,8 +2,10 @@ import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.NoArgsConstructor; @Builder +@NoArgsConstructor @AllArgsConstructor public class Options { diff --git a/src/main/java/ai/pluggy/client/request/TransactionsCursorSearchRequest.java b/src/main/java/ai/pluggy/client/request/TransactionsCursorSearchRequest.java new file mode 100644 index 0000000..122b187 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/TransactionsCursorSearchRequest.java @@ -0,0 +1,97 @@ +package ai.pluggy.client.request; + +import static ai.pluggy.utils.Asserts.assertNotEmpty; +import static ai.pluggy.utils.Asserts.assertNotNull; + +import java.util.HashMap; +import java.util.List; + +/** + * Query params for GET /v2/transactions (cursor pagination). {@code accountId} is passed + * separately to the service method. The API rejects unknown query params, so only the filters + * below can be sent. + */ +public class TransactionsCursorSearchRequest extends HashMap { + + /** + * @param ids List - list of transaction ids to return + * @return this instance, useful to continue adding params + */ + public TransactionsCursorSearchRequest ids(List ids) { + assertNotEmpty(ids, "ids"); + put("ids", String.join(",", ids)); + return this; + } + + /** + * @param dateFrom String - transaction date lower bound, 'YYYY-MM-DD' or ISO-8601 date. + * Cannot be combined with {@link #createdAtFrom(String)}. + * @return this instance, useful to continue adding params + */ + public TransactionsCursorSearchRequest dateFrom(String dateFrom) { + assertNotNull(dateFrom, "dateFrom"); + assertNotCombined("createdAtFrom", "dateFrom"); + put("dateFrom", dateFrom); + return this; + } + + /** + * @param dateTo String - transaction date upper bound, 'YYYY-MM-DD' or ISO-8601 date. + * @return this instance, useful to continue adding params + */ + public TransactionsCursorSearchRequest dateTo(String dateTo) { + assertNotNull(dateTo, "dateTo"); + put("dateTo", dateTo); + return this; + } + + /** + * @param createdAtFrom String - ISO-8601 timestamp; only transactions created in Pluggy from this + * moment on. Cannot be combined with {@link #dateFrom(String)}. + * @return this instance, useful to continue adding params + */ + public TransactionsCursorSearchRequest createdAtFrom(String createdAtFrom) { + assertNotNull(createdAtFrom, "createdAtFrom"); + assertNotCombined("dateFrom", "createdAtFrom"); + put("createdAtFrom", createdAtFrom); + return this; + } + + /** + * @param after String - opaque cursor from a previous response + * ({@code TransactionsCursorResponse#getNextCursor()}). + * @return this instance, useful to continue adding params + */ + public TransactionsCursorSearchRequest after(String after) { + assertNotNull(after, "after"); + put("after", after); + return this; + } + + private void assertNotCombined(String existing, String adding) { + if (containsKey(existing)) { + throw new IllegalArgumentException( + "'" + adding + "' cannot be combined with '" + existing + "'"); + } + } + + public String getIds() { + return (String) get("ids"); + } + + public String getDateFrom() { + return (String) get("dateFrom"); + } + + public String getDateTo() { + return (String) get("dateTo"); + } + + public String getCreatedAtFrom() { + return (String) get("createdAtFrom"); + } + + public String getAfter() { + return (String) get("after"); + } +} diff --git a/src/main/java/ai/pluggy/client/response/Account.java b/src/main/java/ai/pluggy/client/response/Account.java index 368f549..0a3de90 100644 --- a/src/main/java/ai/pluggy/client/response/Account.java +++ b/src/main/java/ai/pluggy/client/response/Account.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Account { String id; diff --git a/src/main/java/ai/pluggy/client/response/AccountBalance.java b/src/main/java/ai/pluggy/client/response/AccountBalance.java new file mode 100644 index 0000000..d49eee2 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/AccountBalance.java @@ -0,0 +1,22 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * GET /accounts/{id}/balance response: the account balance fetched in real time from the + * institution. Only available for Open Finance connectors. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccountBalance { + + Double balance; + String currencyCode; + /** ISO-8601 timestamp of when the institution last updated the balance. */ + String updateDateTime; +} diff --git a/src/main/java/ai/pluggy/client/response/AccountsResponse.java b/src/main/java/ai/pluggy/client/response/AccountsResponse.java index 889553e..c46890f 100644 --- a/src/main/java/ai/pluggy/client/response/AccountsResponse.java +++ b/src/main/java/ai/pluggy/client/response/AccountsResponse.java @@ -2,11 +2,15 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class AccountsResponse { List results; diff --git a/src/main/java/ai/pluggy/client/response/Address.java b/src/main/java/ai/pluggy/client/response/Address.java index cd0322d..1000da5 100644 --- a/src/main/java/ai/pluggy/client/response/Address.java +++ b/src/main/java/ai/pluggy/client/response/Address.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Address { String fullAddress; diff --git a/src/main/java/ai/pluggy/client/response/AuthResponse.java b/src/main/java/ai/pluggy/client/response/AuthResponse.java index a87f658..b244e15 100644 --- a/src/main/java/ai/pluggy/client/response/AuthResponse.java +++ b/src/main/java/ai/pluggy/client/response/AuthResponse.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class AuthResponse { String apiKey; diff --git a/src/main/java/ai/pluggy/client/response/BankData.java b/src/main/java/ai/pluggy/client/response/BankData.java index 537ed26..0c90f85 100644 --- a/src/main/java/ai/pluggy/client/response/BankData.java +++ b/src/main/java/ai/pluggy/client/response/BankData.java @@ -2,11 +2,15 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class BankData { String transferNumber; diff --git a/src/main/java/ai/pluggy/client/response/Bill.java b/src/main/java/ai/pluggy/client/response/Bill.java index 8ca13ad..a6a3507 100644 --- a/src/main/java/ai/pluggy/client/response/Bill.java +++ b/src/main/java/ai/pluggy/client/response/Bill.java @@ -1,13 +1,17 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.Date; import java.util.List; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Bill { String id; diff --git a/src/main/java/ai/pluggy/client/response/BillsResponse.java b/src/main/java/ai/pluggy/client/response/BillsResponse.java index 2e3a68d..dd0183b 100644 --- a/src/main/java/ai/pluggy/client/response/BillsResponse.java +++ b/src/main/java/ai/pluggy/client/response/BillsResponse.java @@ -1,12 +1,16 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.List; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class BillsResponse { List results; diff --git a/src/main/java/ai/pluggy/client/response/BusinessOtherDocument.java b/src/main/java/ai/pluggy/client/response/BusinessOtherDocument.java index e1ee7d0..ea125e4 100644 --- a/src/main/java/ai/pluggy/client/response/BusinessOtherDocument.java +++ b/src/main/java/ai/pluggy/client/response/BusinessOtherDocument.java @@ -2,14 +2,18 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * Additional document for businesses headquartered abroad and not required to register a CNPJ. */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class BusinessOtherDocument { /** Type of the document (e.g. 'EIN'). */ diff --git a/src/main/java/ai/pluggy/client/response/BusinessParty.java b/src/main/java/ai/pluggy/client/response/BusinessParty.java index 2654497..4435012 100644 --- a/src/main/java/ai/pluggy/client/response/BusinessParty.java +++ b/src/main/java/ai/pluggy/client/response/BusinessParty.java @@ -2,8 +2,10 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * Partner or administrator of a business. @@ -12,6 +14,8 @@ */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class BusinessParty { BusinessPartyType type; diff --git a/src/main/java/ai/pluggy/client/response/CategoriesResponse.java b/src/main/java/ai/pluggy/client/response/CategoriesResponse.java index 5dc2bc8..2bc97e6 100644 --- a/src/main/java/ai/pluggy/client/response/CategoriesResponse.java +++ b/src/main/java/ai/pluggy/client/response/CategoriesResponse.java @@ -2,11 +2,15 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class CategoriesResponse { List results; diff --git a/src/main/java/ai/pluggy/client/response/Category.java b/src/main/java/ai/pluggy/client/response/Category.java index 17741c3..0704ea5 100644 --- a/src/main/java/ai/pluggy/client/response/Category.java +++ b/src/main/java/ai/pluggy/client/response/Category.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Category { String id; diff --git a/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java b/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java index fb9e25f..ba96668 100644 --- a/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java +++ b/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ConnectTokenResponse { String accessToken; diff --git a/src/main/java/ai/pluggy/client/response/Connector.java b/src/main/java/ai/pluggy/client/response/Connector.java index 4157974..75df458 100644 --- a/src/main/java/ai/pluggy/client/response/Connector.java +++ b/src/main/java/ai/pluggy/client/response/Connector.java @@ -3,11 +3,15 @@ import java.util.Date; import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Connector { Integer id; String name; diff --git a/src/main/java/ai/pluggy/client/response/ConnectorIncident.java b/src/main/java/ai/pluggy/client/response/ConnectorIncident.java index 276c990..98148d7 100644 --- a/src/main/java/ai/pluggy/client/response/ConnectorIncident.java +++ b/src/main/java/ai/pluggy/client/response/ConnectorIncident.java @@ -2,8 +2,10 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * An incident affecting a connector right now, as published on @@ -14,6 +16,8 @@ */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ConnectorIncident { String id; diff --git a/src/main/java/ai/pluggy/client/response/ConnectorsResponse.java b/src/main/java/ai/pluggy/client/response/ConnectorsResponse.java index 1f6f188..d117d79 100644 --- a/src/main/java/ai/pluggy/client/response/ConnectorsResponse.java +++ b/src/main/java/ai/pluggy/client/response/ConnectorsResponse.java @@ -2,14 +2,18 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * GET /connectors response entity */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ConnectorsResponse { List results; diff --git a/src/main/java/ai/pluggy/client/response/CredentialSelectOption.java b/src/main/java/ai/pluggy/client/response/CredentialSelectOption.java index b166f2c..d3f3460 100644 --- a/src/main/java/ai/pluggy/client/response/CredentialSelectOption.java +++ b/src/main/java/ai/pluggy/client/response/CredentialSelectOption.java @@ -3,10 +3,12 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @AllArgsConstructor @Builder +@NoArgsConstructor public class CredentialSelectOption { String label; diff --git a/src/main/java/ai/pluggy/client/response/CreditCardAccountPaymentType.java b/src/main/java/ai/pluggy/client/response/CreditCardAccountPaymentType.java new file mode 100644 index 0000000..eb77a08 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/CreditCardAccountPaymentType.java @@ -0,0 +1,19 @@ +package ai.pluggy.client.response; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Whether a credit card transaction was paid in a single charge or in installments. + */ +@AllArgsConstructor +public enum CreditCardAccountPaymentType { + @SerializedName("SINGLE") + SINGLE("SINGLE"), + @SerializedName("INSTALLMENT") + INSTALLMENT("INSTALLMENT"); + + @Getter + private String value; +} diff --git a/src/main/java/ai/pluggy/client/response/CreditData.java b/src/main/java/ai/pluggy/client/response/CreditData.java index 4168fa9..ebf4284 100644 --- a/src/main/java/ai/pluggy/client/response/CreditData.java +++ b/src/main/java/ai/pluggy/client/response/CreditData.java @@ -1,12 +1,16 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.List; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class CreditData { String level; diff --git a/src/main/java/ai/pluggy/client/response/DeleteItemResponse.java b/src/main/java/ai/pluggy/client/response/DeleteItemResponse.java index 6b2a212..661be51 100644 --- a/src/main/java/ai/pluggy/client/response/DeleteItemResponse.java +++ b/src/main/java/ai/pluggy/client/response/DeleteItemResponse.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class DeleteItemResponse { Integer count; diff --git a/src/main/java/ai/pluggy/client/response/DisaggregatedCreditLimit.java b/src/main/java/ai/pluggy/client/response/DisaggregatedCreditLimit.java index 5c0cb41..b95274b 100644 --- a/src/main/java/ai/pluggy/client/response/DisaggregatedCreditLimit.java +++ b/src/main/java/ai/pluggy/client/response/DisaggregatedCreditLimit.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class DisaggregatedCreditLimit { CreditCardLimitLineName lineName; diff --git a/src/main/java/ai/pluggy/client/response/DocumentNumber.java b/src/main/java/ai/pluggy/client/response/DocumentNumber.java index 74f5630..86248cb 100644 --- a/src/main/java/ai/pluggy/client/response/DocumentNumber.java +++ b/src/main/java/ai/pluggy/client/response/DocumentNumber.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class DocumentNumber { String type; String value; diff --git a/src/main/java/ai/pluggy/client/response/Email.java b/src/main/java/ai/pluggy/client/response/Email.java index 0a3068a..2d4823d 100644 --- a/src/main/java/ai/pluggy/client/response/Email.java +++ b/src/main/java/ai/pluggy/client/response/Email.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Email { EmailType type; diff --git a/src/main/java/ai/pluggy/client/response/ErrorDetail.java b/src/main/java/ai/pluggy/client/response/ErrorDetail.java index de17cf9..ff3b9f4 100644 --- a/src/main/java/ai/pluggy/client/response/ErrorDetail.java +++ b/src/main/java/ai/pluggy/client/response/ErrorDetail.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ErrorDetail { String code; diff --git a/src/main/java/ai/pluggy/client/response/Expenses.java b/src/main/java/ai/pluggy/client/response/Expenses.java index 0ab8384..dfb06fb 100644 --- a/src/main/java/ai/pluggy/client/response/Expenses.java +++ b/src/main/java/ai/pluggy/client/response/Expenses.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Expenses { String id; String transactionId; diff --git a/src/main/java/ai/pluggy/client/response/FinancialCharge.java b/src/main/java/ai/pluggy/client/response/FinancialCharge.java index bcf8db0..c05aff8 100644 --- a/src/main/java/ai/pluggy/client/response/FinancialCharge.java +++ b/src/main/java/ai/pluggy/client/response/FinancialCharge.java @@ -1,12 +1,16 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.Date; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class FinancialCharge { String id; String type; diff --git a/src/main/java/ai/pluggy/client/response/GeographicCoordinates.java b/src/main/java/ai/pluggy/client/response/GeographicCoordinates.java index 8b16286..52eabef 100644 --- a/src/main/java/ai/pluggy/client/response/GeographicCoordinates.java +++ b/src/main/java/ai/pluggy/client/response/GeographicCoordinates.java @@ -1,13 +1,17 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * Geographic coordinates in decimal degrees, WGS84 reference system. */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class GeographicCoordinates { /** Between -90 and 90. */ diff --git a/src/main/java/ai/pluggy/client/response/Health.java b/src/main/java/ai/pluggy/client/response/Health.java index 1d069e9..b3150f3 100644 --- a/src/main/java/ai/pluggy/client/response/Health.java +++ b/src/main/java/ai/pluggy/client/response/Health.java @@ -2,11 +2,15 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Health { ConnectorStatus status; diff --git a/src/main/java/ai/pluggy/client/response/IdentityRelation.java b/src/main/java/ai/pluggy/client/response/IdentityRelation.java index 3b2e17c..fa7111f 100644 --- a/src/main/java/ai/pluggy/client/response/IdentityRelation.java +++ b/src/main/java/ai/pluggy/client/response/IdentityRelation.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class IdentityRelation { // 'Mother' | 'Father' | 'Spouse' diff --git a/src/main/java/ai/pluggy/client/response/IdentityResponse.java b/src/main/java/ai/pluggy/client/response/IdentityResponse.java index 2b8d475..a2b2f63 100644 --- a/src/main/java/ai/pluggy/client/response/IdentityResponse.java +++ b/src/main/java/ai/pluggy/client/response/IdentityResponse.java @@ -3,14 +3,18 @@ import java.util.Date; import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * GET /identity */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class IdentityResponse { String id; diff --git a/src/main/java/ai/pluggy/client/response/Investment.java b/src/main/java/ai/pluggy/client/response/Investment.java index 61a628f..16b6d3b 100644 --- a/src/main/java/ai/pluggy/client/response/Investment.java +++ b/src/main/java/ai/pluggy/client/response/Investment.java @@ -2,11 +2,15 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Investment { String id; String itemId; diff --git a/src/main/java/ai/pluggy/client/response/InvestmentInstitution.java b/src/main/java/ai/pluggy/client/response/InvestmentInstitution.java index edf7c32..f6716e5 100644 --- a/src/main/java/ai/pluggy/client/response/InvestmentInstitution.java +++ b/src/main/java/ai/pluggy/client/response/InvestmentInstitution.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class InvestmentInstitution { String name; String number; diff --git a/src/main/java/ai/pluggy/client/response/InvestmentMetadata.java b/src/main/java/ai/pluggy/client/response/InvestmentMetadata.java index 9c382e4..12e59fa 100644 --- a/src/main/java/ai/pluggy/client/response/InvestmentMetadata.java +++ b/src/main/java/ai/pluggy/client/response/InvestmentMetadata.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class InvestmentMetadata { String taxRegime; String proposalNumber; diff --git a/src/main/java/ai/pluggy/client/response/InvestmentTransaction.java b/src/main/java/ai/pluggy/client/response/InvestmentTransaction.java index af86bb5..34f0498 100644 --- a/src/main/java/ai/pluggy/client/response/InvestmentTransaction.java +++ b/src/main/java/ai/pluggy/client/response/InvestmentTransaction.java @@ -2,11 +2,15 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class InvestmentTransaction { String id; InvestmentTransactionType type; diff --git a/src/main/java/ai/pluggy/client/response/InvestmentsResponse.java b/src/main/java/ai/pluggy/client/response/InvestmentsResponse.java index f1c3876..eebc1b5 100644 --- a/src/main/java/ai/pluggy/client/response/InvestmentsResponse.java +++ b/src/main/java/ai/pluggy/client/response/InvestmentsResponse.java @@ -1,11 +1,15 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.util.List; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class InvestmentsResponse { List results; diff --git a/src/main/java/ai/pluggy/client/response/ItemError.java b/src/main/java/ai/pluggy/client/response/ItemError.java index 5e7589c..3dfcb7c 100644 --- a/src/main/java/ai/pluggy/client/response/ItemError.java +++ b/src/main/java/ai/pluggy/client/response/ItemError.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ItemError { String message; diff --git a/src/main/java/ai/pluggy/client/response/ItemProductState.java b/src/main/java/ai/pluggy/client/response/ItemProductState.java index cde3205..0abfecb 100644 --- a/src/main/java/ai/pluggy/client/response/ItemProductState.java +++ b/src/main/java/ai/pluggy/client/response/ItemProductState.java @@ -2,11 +2,15 @@ import java.util.Date; import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ItemProductState { /** Whether product was collected in this last execution or not */ boolean isUpdated; diff --git a/src/main/java/ai/pluggy/client/response/ItemProductStepWarning.java b/src/main/java/ai/pluggy/client/response/ItemProductStepWarning.java index 6f0a35c..39ae7fc 100644 --- a/src/main/java/ai/pluggy/client/response/ItemProductStepWarning.java +++ b/src/main/java/ai/pluggy/client/response/ItemProductStepWarning.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ItemProductStepWarning { /** The specific warning code */ String code; diff --git a/src/main/java/ai/pluggy/client/response/ItemResource.java b/src/main/java/ai/pluggy/client/response/ItemResource.java index 3e60201..f927d42 100644 --- a/src/main/java/ai/pluggy/client/response/ItemResource.java +++ b/src/main/java/ai/pluggy/client/response/ItemResource.java @@ -1,7 +1,9 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * One resource the financial institution declared for an item's Open Finance consent, reported @@ -9,6 +11,8 @@ */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ItemResource { /** The institution's identifier for the resource. */ diff --git a/src/main/java/ai/pluggy/client/response/ItemResponse.java b/src/main/java/ai/pluggy/client/response/ItemResponse.java index 17f4fae..db09786 100644 --- a/src/main/java/ai/pluggy/client/response/ItemResponse.java +++ b/src/main/java/ai/pluggy/client/response/ItemResponse.java @@ -2,11 +2,15 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ItemResponse { String id; diff --git a/src/main/java/ai/pluggy/client/response/ItemStatusDetail.java b/src/main/java/ai/pluggy/client/response/ItemStatusDetail.java index d7d64d6..1c5b02d 100644 --- a/src/main/java/ai/pluggy/client/response/ItemStatusDetail.java +++ b/src/main/java/ai/pluggy/client/response/ItemStatusDetail.java @@ -1,7 +1,9 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * Only available when item.status is 'PARTIAL_SUCCESS'. @@ -17,6 +19,8 @@ */ @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ItemStatusDetail { /** * Collection details for 'ACCOUNTS' product, or null if it was not requested at diff --git a/src/main/java/ai/pluggy/client/response/MaritalStatus.java b/src/main/java/ai/pluggy/client/response/MaritalStatus.java index e0e5377..39ef7ee 100644 --- a/src/main/java/ai/pluggy/client/response/MaritalStatus.java +++ b/src/main/java/ai/pluggy/client/response/MaritalStatus.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class MaritalStatus { MaritalStatusCode code; diff --git a/src/main/java/ai/pluggy/client/response/Nationality.java b/src/main/java/ai/pluggy/client/response/Nationality.java index aa8c8fd..3f72795 100644 --- a/src/main/java/ai/pluggy/client/response/Nationality.java +++ b/src/main/java/ai/pluggy/client/response/Nationality.java @@ -2,11 +2,15 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Nationality { Boolean hasBrazilianNationality; diff --git a/src/main/java/ai/pluggy/client/response/NationalityDocument.java b/src/main/java/ai/pluggy/client/response/NationalityDocument.java index 74c716e..e421342 100644 --- a/src/main/java/ai/pluggy/client/response/NationalityDocument.java +++ b/src/main/java/ai/pluggy/client/response/NationalityDocument.java @@ -2,11 +2,15 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class NationalityDocument { String type; diff --git a/src/main/java/ai/pluggy/client/response/OtherDocument.java b/src/main/java/ai/pluggy/client/response/OtherDocument.java index c7a9138..9364ed6 100644 --- a/src/main/java/ai/pluggy/client/response/OtherDocument.java +++ b/src/main/java/ai/pluggy/client/response/OtherDocument.java @@ -2,11 +2,15 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class OtherDocument { OtherDocumentType type; diff --git a/src/main/java/ai/pluggy/client/response/OtherNationality.java b/src/main/java/ai/pluggy/client/response/OtherNationality.java index 09acb6e..79c0635 100644 --- a/src/main/java/ai/pluggy/client/response/OtherNationality.java +++ b/src/main/java/ai/pluggy/client/response/OtherNationality.java @@ -2,11 +2,15 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class OtherNationality { /** Country code in alpha3 ISO-3166 format. */ diff --git a/src/main/java/ai/pluggy/client/response/Passport.java b/src/main/java/ai/pluggy/client/response/Passport.java index a8b43d7..15f8169 100644 --- a/src/main/java/ai/pluggy/client/response/Passport.java +++ b/src/main/java/ai/pluggy/client/response/Passport.java @@ -2,11 +2,15 @@ import java.util.Date; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class Passport { String number; diff --git a/src/main/java/ai/pluggy/client/response/ReservedBalance.java b/src/main/java/ai/pluggy/client/response/ReservedBalance.java index 220a0ae..b84281c 100644 --- a/src/main/java/ai/pluggy/client/response/ReservedBalance.java +++ b/src/main/java/ai/pluggy/client/response/ReservedBalance.java @@ -2,11 +2,15 @@ import java.util.List; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ReservedBalance { String name; diff --git a/src/main/java/ai/pluggy/client/response/ReservedBalanceAmount.java b/src/main/java/ai/pluggy/client/response/ReservedBalanceAmount.java index 95273a8..14d480c 100644 --- a/src/main/java/ai/pluggy/client/response/ReservedBalanceAmount.java +++ b/src/main/java/ai/pluggy/client/response/ReservedBalanceAmount.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ReservedBalanceAmount { Double amount; diff --git a/src/main/java/ai/pluggy/client/response/ReservedBalanceRemuneration.java b/src/main/java/ai/pluggy/client/response/ReservedBalanceRemuneration.java index 7aeb3ad..81bee79 100644 --- a/src/main/java/ai/pluggy/client/response/ReservedBalanceRemuneration.java +++ b/src/main/java/ai/pluggy/client/response/ReservedBalanceRemuneration.java @@ -1,10 +1,14 @@ package ai.pluggy.client.response; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ReservedBalanceRemuneration { Double preFixedRate; diff --git a/src/main/java/ai/pluggy/client/response/TransactionCreditCardMetadata.java b/src/main/java/ai/pluggy/client/response/TransactionCreditCardMetadata.java index 654f36d..c2ebdaa 100644 --- a/src/main/java/ai/pluggy/client/response/TransactionCreditCardMetadata.java +++ b/src/main/java/ai/pluggy/client/response/TransactionCreditCardMetadata.java @@ -18,4 +18,21 @@ public class TransactionCreditCardMetadata { CreditCardAccountOtherCreditType otherCreditsType; String otherCreditsAdditionalInfo; String billForecastDate; + /** + * Whether the transaction was a single charge or an installment. + * Only returned for Open Finance connectors; not retroactive. + */ + CreditCardAccountPaymentType paymentType; + /** + * Date the institution posted the transaction to a bill, in 'YYYY-MM-DD' format, as reported + * by the institution. Null when the transaction was not posted to a bill yet or the institution + * does not report it. Kept as a String to avoid timezone shifts on a date-only value. + * Only returned for Open Finance connectors; not retroactive. + */ + String billPostDate; + /** + * ISO-8601 timestamp of the transaction, as reported by the institution. + * Only returned for Open Finance connectors; not retroactive. + */ + String transactionDateTime; } diff --git a/src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java b/src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java new file mode 100644 index 0000000..d6f64c7 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java @@ -0,0 +1,78 @@ +package ai.pluggy.client.response; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * GET /v2/transactions response: one cursor-paginated page of transactions. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionsCursorResponse { + + List results; + /** + * Query string to fetch the next page (e.g. {@code ?accountId=...&after=}), or null on + * the last page. Use {@link #getNextCursor()} to extract the cursor. + */ + String next; + + /** + * @return the URL-decoded {@code after} cursor to request the next page with, or null when this + * is the last page. + */ + public String getNextCursor() { + return parseAfterCursor(next); + } + + /** + * @return true when there is another page to fetch + */ + public boolean hasNext() { + return getNextCursor() != null; + } + + /** + * Extracts the URL-decoded {@code after} parameter from a {@code next} query string. + * + * @param next String - the {@code next} value of a /v2/transactions response + * @return the cursor, or null if {@code next} is null or has no {@code after} parameter + */ + public static String parseAfterCursor(String next) { + if (next == null || next.isEmpty()) { + return null; + } + String query = next; + int questionMark = query.indexOf('?'); + if (questionMark >= 0) { + query = query.substring(questionMark + 1); + } + int fragment = query.indexOf('#'); + if (fragment >= 0) { + query = query.substring(0, fragment); + } + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + String key = eq >= 0 ? pair.substring(0, eq) : pair; + if ("after".equals(key)) { + String value = eq >= 0 ? pair.substring(eq + 1) : ""; + if (value.isEmpty()) { + return null; + } + try { + return URLDecoder.decode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + } + return null; + } +} diff --git a/src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java b/src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java new file mode 100644 index 0000000..71bd345 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java @@ -0,0 +1,51 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.pluggy.client.response.Account; +import ai.pluggy.client.response.AccountBalance; +import ai.pluggy.client.response.AccountsResponse; +import ai.pluggy.client.response.Bill; +import ai.pluggy.client.response.Connector; +import ai.pluggy.client.response.CredentialSelectOption; +import ai.pluggy.client.response.IdentityResponse; +import ai.pluggy.client.response.Investment; +import ai.pluggy.client.response.ItemResponse; +import ai.pluggy.client.response.TransactionsCursorResponse; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Response classes built with Lombok's @Builder must keep a public no-args constructor, so that + * JSON mappers that require one (e.g. Jackson) can deserialize them, not only Gson. + */ +public class BuilderNoArgsConstructorTest { + + private static final List> RESPONSE_CLASSES = Arrays.asList( + Account.class, AccountBalance.class, AccountsResponse.class, Bill.class, Connector.class, + CredentialSelectOption.class, IdentityResponse.class, Investment.class, ItemResponse.class, + TransactionsCursorResponse.class); + + @Test + void builderResponseClasses_havePublicNoArgsConstructor() throws Exception { + for (Class clazz : RESPONSE_CLASSES) { + Constructor constructor = clazz.getDeclaredConstructor(); + assertTrue(Modifier.isPublic(constructor.getModifiers()), clazz.getSimpleName()); + assertNotNull(constructor.newInstance(), clazz.getSimpleName()); + } + } + + @Test + void builderStillWorksAlongsideNoArgsConstructor() { + Account account = Account.builder().id("acc-1").balance(10.0).build(); + assertEquals("acc-1", account.getId()); + Account empty = new Account(); + empty.setId("acc-2"); + assertEquals("acc-2", empty.getId()); + } +} diff --git a/src/test/java/ai/pluggy/client/unit/TransactionsV2Test.java b/src/test/java/ai/pluggy/client/unit/TransactionsV2Test.java new file mode 100644 index 0000000..6ed9da5 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/TransactionsV2Test.java @@ -0,0 +1,170 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.pluggy.client.PluggyClient; +import ai.pluggy.client.request.TransactionsCursorSearchRequest; +import ai.pluggy.client.response.AccountBalance; +import ai.pluggy.client.response.CreditCardAccountPaymentType; +import ai.pluggy.client.response.Transaction; +import ai.pluggy.client.response.TransactionCreditCardMetadata; +import ai.pluggy.client.response.TransactionsCursorResponse; +import java.io.IOException; +import java.util.Arrays; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import retrofit2.Response; + +public class TransactionsV2Test { + + private static final String ACCOUNT_ID = "03fc3a6f-2b5e-4e8e-9f4c-1a2b3c4d5e6f"; + + private PluggyClient client; + private Request lastRequest; + private String responseJson; + + @BeforeEach + void setUp() { + PluggyClient.PluggyClientBuilder builder = PluggyClient.builder() + .clientIdAndSecret("client-id", "client-secret") + .noAuthInterceptor(); + // answer every request with a canned body, so no request leaves the process + builder.okHttpClientBuilder().addInterceptor(chain -> { + lastRequest = chain.request(); + return new okhttp3.Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(ResponseBody.create(responseJson, MediaType.parse("application/json"))) + .build(); + }); + client = builder.build(); + } + + @Test + void getTransactionsV2_parsesPageAndCreditCardMetadata() throws IOException { + responseJson = "{\"results\":[{\"id\":\"t-1\",\"accountId\":\"" + ACCOUNT_ID + "\"," + + "\"amount\":-120.5,\"date\":\"2026-09-10T00:00:00.000Z\"," + + "\"creditCardMetadata\":{\"installmentNumber\":1,\"totalInstallments\":3," + + "\"paymentType\":\"INSTALLMENT\",\"billPostDate\":\"2026-09-15\"," + + "\"transactionDateTime\":\"2026-09-10T14:32:11.000Z\"," + + "\"billForecastDate\":\"2026-10-05\"}}]," + + "\"next\":\"?accountId=" + ACCOUNT_ID + "&dateFrom=2026-09-01&after=abc%2B123%3D%3D\"}"; + + Response response = client.service() + .getTransactionsV2(ACCOUNT_ID) + .execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/v2/transactions", lastRequest.url().encodedPath()); + assertEquals(ACCOUNT_ID, lastRequest.url().queryParameter("accountId")); + assertEquals(1, lastRequest.url().querySize()); + + TransactionsCursorResponse page = response.body(); + assertNotNull(page); + assertEquals(1, page.getResults().size()); + assertEquals("abc+123==", page.getNextCursor()); + assertTrue(page.hasNext()); + + Transaction transaction = page.getResults().get(0); + TransactionCreditCardMetadata metadata = transaction.getCreditCardMetadata(); + assertEquals(CreditCardAccountPaymentType.INSTALLMENT, metadata.getPaymentType()); + assertEquals("2026-09-15", metadata.getBillPostDate()); + assertEquals("2026-09-10T14:32:11.000Z", metadata.getTransactionDateTime()); + assertEquals("2026-10-05", metadata.getBillForecastDate()); + assertEquals(3, metadata.getTotalInstallments()); + } + + @Test + void getTransactionsV2_lastPage_hasNoCursor_andMissingFieldsAreNull() throws IOException { + responseJson = "{\"results\":[{\"id\":\"t-2\",\"creditCardMetadata\":" + + "{\"paymentType\":\"SOME_FUTURE_TYPE\",\"billPostDate\":null}}],\"next\":null}"; + + TransactionsCursorResponse page = client.service() + .getTransactionsV2(ACCOUNT_ID) + .execute() + .body(); + + assertNotNull(page); + assertNull(page.getNext()); + assertNull(page.getNextCursor()); + assertFalse(page.hasNext()); + TransactionCreditCardMetadata metadata = page.getResults().get(0).getCreditCardMetadata(); + // a payment type added after this SDK release deserializes as null, like every SDK enum + assertNull(metadata.getPaymentType()); + assertNull(metadata.getBillPostDate()); + assertNull(metadata.getTransactionDateTime()); + } + + @Test + void getTransactionsV2_withFilters_sendsOnlyWhitelistedParams() throws IOException { + responseJson = "{\"results\":[],\"next\":null}"; + + TransactionsCursorSearchRequest request = new TransactionsCursorSearchRequest() + .ids(Arrays.asList("a", "b")) + .dateFrom("2026-09-01") + .dateTo("2026-09-30") + .after("abc+123=="); + client.service().getTransactionsV2(ACCOUNT_ID, request).execute(); + + assertEquals("/v2/transactions", lastRequest.url().encodedPath()); + assertEquals(ACCOUNT_ID, lastRequest.url().queryParameter("accountId")); + assertEquals("a,b", lastRequest.url().queryParameter("ids")); + assertEquals("2026-09-01", lastRequest.url().queryParameter("dateFrom")); + assertEquals("2026-09-30", lastRequest.url().queryParameter("dateTo")); + // the decoded cursor is re-encoded on the wire and round-trips intact + assertEquals("abc+123==", lastRequest.url().queryParameter("after")); + assertEquals(5, lastRequest.url().querySize()); + assertNull(lastRequest.url().queryParameter("page")); + assertNull(lastRequest.url().queryParameter("pageSize")); + } + + @Test + void cursorSearchRequest_dateFromAndCreatedAtFrom_areMutuallyExclusive() { + assertThrows(IllegalArgumentException.class, () -> new TransactionsCursorSearchRequest() + .dateFrom("2026-09-01") + .createdAtFrom("2026-09-01T00:00:00.000Z")); + assertThrows(IllegalArgumentException.class, () -> new TransactionsCursorSearchRequest() + .createdAtFrom("2026-09-01T00:00:00.000Z") + .dateFrom("2026-09-01")); + } + + @Test + void parseAfterCursor_handlesEdgeCases() { + assertNull(TransactionsCursorResponse.parseAfterCursor(null)); + assertNull(TransactionsCursorResponse.parseAfterCursor("")); + assertNull(TransactionsCursorResponse.parseAfterCursor("?accountId=x")); + assertNull(TransactionsCursorResponse.parseAfterCursor("?accountId=x&after=")); + assertEquals("c1", TransactionsCursorResponse.parseAfterCursor("?after=c1")); + assertEquals("c1", TransactionsCursorResponse.parseAfterCursor("after=c1&accountId=x")); + assertEquals("c1", + TransactionsCursorResponse.parseAfterCursor("https://api.pluggy.ai/v2/transactions?a=1&after=c1")); + // a param that merely ends in "after" is not the cursor + assertNull(TransactionsCursorResponse.parseAfterCursor("?notafter=c1")); + } + + @Test + void getAccountBalance_parsesBalance() throws IOException { + responseJson = "{\"balance\":1523.47,\"currencyCode\":\"BRL\"," + + "\"updateDateTime\":\"2026-10-01T10:15:00.000Z\"}"; + + AccountBalance balance = client.service().getAccountBalance(ACCOUNT_ID).execute().body(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/accounts/" + ACCOUNT_ID + "/balance", lastRequest.url().encodedPath()); + assertNotNull(balance); + assertEquals(1523.47, balance.getBalance()); + assertEquals("BRL", balance.getCurrencyCode()); + assertEquals("2026-10-01T10:15:00.000Z", balance.getUpdateDateTime()); + } +} From ea3dd863fb7bead5b748c1334377153688c9e120 Mon Sep 17 00:00:00 2001 From: Gabriel Pan Gantes Date: Sun, 4 Oct 2026 23:24:09 +0200 Subject: [PATCH 2/3] feat(item): add getItems for GET /v2/items (opt-in, paid plans only) Cursor-paginated item listing with ItemsCursorSearchRequest and ItemsCursorResponse. Teams without the feature enabled get 403 LIST_ITEMS_FEATURE_NOT_ENABLED; ErrorResponse now exposes codeDescription so callers can tell that apart. The cursor parser moves to a shared Cursors util. --- README.md | 25 ++++ .../ai/pluggy/client/PluggyApiService.java | 28 ++++ .../request/ItemsCursorSearchRequest.java | 70 +++++++++ .../pluggy/client/response/ErrorResponse.java | 5 + .../client/response/ItemsCursorResponse.java | 46 ++++++ .../response/TransactionsCursorResponse.java | 32 +--- src/main/java/ai/pluggy/utils/Cursors.java | 51 +++++++ .../ai/pluggy/client/unit/ItemsV2Test.java | 139 ++++++++++++++++++ 8 files changed, 366 insertions(+), 30 deletions(-) create mode 100644 src/main/java/ai/pluggy/client/request/ItemsCursorSearchRequest.java create mode 100644 src/main/java/ai/pluggy/client/response/ItemsCursorResponse.java create mode 100644 src/main/java/ai/pluggy/utils/Cursors.java create mode 100644 src/test/java/ai/pluggy/client/unit/ItemsV2Test.java diff --git a/README.md b/README.md index e6f1780..c34026f 100644 --- a/README.md +++ b/README.md @@ -73,3 +73,28 @@ while (true) { request = new TransactionsCursorSearchRequest().dateFrom("2026-01-01").after(page.getNextCursor()); } ``` + +### Listing items (opt-in) + +> **Opt-in, paid plans only.** Listing items is disabled by default and is only available to paid-plan teams that have explicitly requested it from Pluggy support. Teams without it enabled get `403` with `codeDescription` `LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId` when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use `getItem(id)` instead. + +`getItems` (`GET /v2/items`) returns items newest first, optionally filtered by `clientUserId` and/or `connectorId`, and is cursor-paginated like `getTransactionsV2`: + +```java +List items = new ArrayList<>(); +ItemsCursorSearchRequest request = new ItemsCursorSearchRequest().clientUserId("user-123"); +while (true) { + Response response = pluggyClient.service().getItems(request).execute(); + if (!response.isSuccessful()) { + ErrorResponse error = pluggyClient.parseError(response); + // "LIST_ITEMS_FEATURE_NOT_ENABLED" when listing items is not enabled for your team + throw new IllegalStateException(error.getCodeDescription() + ": " + error.getMessage()); + } + ItemsCursorResponse page = response.body(); + items.addAll(page.getResults()); + if (!page.hasNext()) { + break; + } + request = new ItemsCursorSearchRequest().clientUserId("user-123").after(page.getNextCursor()); +} +``` diff --git a/src/main/java/ai/pluggy/client/PluggyApiService.java b/src/main/java/ai/pluggy/client/PluggyApiService.java index 1176aab..adf79da 100644 --- a/src/main/java/ai/pluggy/client/PluggyApiService.java +++ b/src/main/java/ai/pluggy/client/PluggyApiService.java @@ -6,6 +6,7 @@ import ai.pluggy.client.request.CreateItemRequest; import ai.pluggy.client.request.InvestmentTransactionsSearchRequest; import ai.pluggy.client.request.ItemResourcesSearchRequest; +import ai.pluggy.client.request.ItemsCursorSearchRequest; import ai.pluggy.client.request.TransactionsCursorSearchRequest; import ai.pluggy.client.request.TransactionsSearchRequest; import ai.pluggy.client.request.UpdateItemMfaRequest; @@ -57,6 +58,33 @@ Call updateItemSendMfa(@Path("id") String itemId, @DELETE("/items/{id}") Call deleteItem(@Path("id") String existingItemId); + /** + * First page of the team's items, newest first, cursor-paginated. Fetch the following pages by + * passing {@link ItemsCursorResponse#getNextCursor()} as + * {@link ItemsCursorSearchRequest#after(String)} until it returns null. + * + *

Availability: opt-in, paid plans only. Listing items is disabled by default and is + * only available to paid-plan teams that have explicitly requested it from Pluggy support. Teams + * without it enabled get {@code 403 LIST_ITEMS_FEATURE_NOT_ENABLED}. For most integrations, + * store each itemId when it is created (Pluggy Connect {@code onSuccess} or the + * {@code item/created} webhook) and use {@link #getItem(String)} instead. + */ + @GET("/v2/items") + Call getItems(); + + /** + * Items filtered by {@code clientUserId} and/or {@code connectorId}, newest first, + * cursor-paginated. See {@link #getItems()}. + * + *

Availability: opt-in, paid plans only. Listing items is disabled by default and is + * only available to paid-plan teams that have explicitly requested it from Pluggy support. Teams + * without it enabled get {@code 403 LIST_ITEMS_FEATURE_NOT_ENABLED}. For most integrations, + * store each itemId when it is created (Pluggy Connect {@code onSuccess} or the + * {@code item/created} webhook) and use {@link #getItem(String)} instead. + */ + @GET("/v2/items") + Call getItems(@QueryMap ItemsCursorSearchRequest itemsCursorSearchRequest); + /** * Open Finance only: the resources the financial institution declared for the item's consent. * Items on other connectors return an empty page. diff --git a/src/main/java/ai/pluggy/client/request/ItemsCursorSearchRequest.java b/src/main/java/ai/pluggy/client/request/ItemsCursorSearchRequest.java new file mode 100644 index 0000000..2f93c95 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/ItemsCursorSearchRequest.java @@ -0,0 +1,70 @@ +package ai.pluggy.client.request; + +import static ai.pluggy.utils.Asserts.assertNotNull; + +import java.util.HashMap; + +/** + * Query params for GET /v2/items (cursor pagination). All filters are optional. The API rejects + * unknown query params, so only the filters below can be sent. + * + *

Availability: opt-in, paid plans only. Listing items is disabled by default and is + * only available to paid-plan teams that have explicitly requested it from Pluggy support. Teams + * without it enabled get {@code 403 LIST_ITEMS_FEATURE_NOT_ENABLED}. For most integrations, store + * each itemId when it is created (Pluggy Connect {@code onSuccess} or the {@code item/created} + * webhook) and use {@code getItem(id)} instead. + */ +public class ItemsCursorSearchRequest extends HashMap { + + private static final int CLIENT_USER_ID_MAX_LENGTH = 255; + + /** + * @param clientUserId String - only items created with this clientUserId (max 255 chars) + * @return this instance, useful to continue adding params + */ + public ItemsCursorSearchRequest clientUserId(String clientUserId) { + assertNotNull(clientUserId, "clientUserId"); + if (clientUserId.length() > CLIENT_USER_ID_MAX_LENGTH) { + throw new IllegalArgumentException( + "'clientUserId' cannot be longer than " + CLIENT_USER_ID_MAX_LENGTH + " characters"); + } + put("clientUserId", clientUserId); + return this; + } + + /** + * @param connectorId Integer - only items of this connector (>= 0) + * @return this instance, useful to continue adding params + */ + public ItemsCursorSearchRequest connectorId(Integer connectorId) { + assertNotNull(connectorId, "connectorId"); + if (connectorId < 0) { + throw new IllegalArgumentException("'connectorId' cannot be negative"); + } + put("connectorId", connectorId); + return this; + } + + /** + * @param after String - opaque cursor from a previous response + * ({@code ItemsCursorResponse#getNextCursor()}). + * @return this instance, useful to continue adding params + */ + public ItemsCursorSearchRequest after(String after) { + assertNotNull(after, "after"); + put("after", after); + return this; + } + + public String getClientUserId() { + return (String) get("clientUserId"); + } + + public Integer getConnectorId() { + return (Integer) get("connectorId"); + } + + public String getAfter() { + return (String) get("after"); + } +} diff --git a/src/main/java/ai/pluggy/client/response/ErrorResponse.java b/src/main/java/ai/pluggy/client/response/ErrorResponse.java index d1ce3ca..4d36324 100644 --- a/src/main/java/ai/pluggy/client/response/ErrorResponse.java +++ b/src/main/java/ai/pluggy/client/response/ErrorResponse.java @@ -11,5 +11,10 @@ public class ErrorResponse { String message; Integer code; + /** + * Machine-readable error identifier, when the API provides one (e.g. + * {@code LIST_ITEMS_FEATURE_NOT_ENABLED}). Null for errors that don't carry it. + */ + String codeDescription; List details; } diff --git a/src/main/java/ai/pluggy/client/response/ItemsCursorResponse.java b/src/main/java/ai/pluggy/client/response/ItemsCursorResponse.java new file mode 100644 index 0000000..ddec49f --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ItemsCursorResponse.java @@ -0,0 +1,46 @@ +package ai.pluggy.client.response; + +import ai.pluggy.utils.Cursors; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * GET /v2/items response: one cursor-paginated page of items, newest first. + * + *

Availability: opt-in, paid plans only. Listing items is disabled by default and is + * only available to paid-plan teams that have explicitly requested it from Pluggy support. Teams + * without it enabled get {@code 403 LIST_ITEMS_FEATURE_NOT_ENABLED}. For most integrations, store + * each itemId when it is created (Pluggy Connect {@code onSuccess} or the {@code item/created} + * webhook) and use {@code getItem(id)} instead. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ItemsCursorResponse { + + List results; + /** + * Query string to fetch the next page (e.g. {@code ?clientUserId=...&after=}), or null + * on the last page. Use {@link #getNextCursor()} to extract the cursor. + */ + String next; + + /** + * @return the URL-decoded {@code after} cursor to request the next page with, or null when this + * is the last page. + */ + public String getNextCursor() { + return Cursors.parseAfterCursor(next); + } + + /** + * @return true when there is another page to fetch + */ + public boolean hasNext() { + return getNextCursor() != null; + } +} diff --git a/src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java b/src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java index d6f64c7..00aa9ae 100644 --- a/src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java +++ b/src/main/java/ai/pluggy/client/response/TransactionsCursorResponse.java @@ -1,7 +1,6 @@ package ai.pluggy.client.response; -import java.io.UnsupportedEncodingException; -import java.net.URLDecoder; +import ai.pluggy.utils.Cursors; import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; @@ -46,33 +45,6 @@ public boolean hasNext() { * @return the cursor, or null if {@code next} is null or has no {@code after} parameter */ public static String parseAfterCursor(String next) { - if (next == null || next.isEmpty()) { - return null; - } - String query = next; - int questionMark = query.indexOf('?'); - if (questionMark >= 0) { - query = query.substring(questionMark + 1); - } - int fragment = query.indexOf('#'); - if (fragment >= 0) { - query = query.substring(0, fragment); - } - for (String pair : query.split("&")) { - int eq = pair.indexOf('='); - String key = eq >= 0 ? pair.substring(0, eq) : pair; - if ("after".equals(key)) { - String value = eq >= 0 ? pair.substring(eq + 1) : ""; - if (value.isEmpty()) { - return null; - } - try { - return URLDecoder.decode(value, "UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new IllegalStateException(e); - } - } - } - return null; + return Cursors.parseAfterCursor(next); } } diff --git a/src/main/java/ai/pluggy/utils/Cursors.java b/src/main/java/ai/pluggy/utils/Cursors.java new file mode 100644 index 0000000..2ad5bc1 --- /dev/null +++ b/src/main/java/ai/pluggy/utils/Cursors.java @@ -0,0 +1,51 @@ +package ai.pluggy.utils; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; + +/** + * Helpers for the cursor-paginated endpoints ({@code GET /v2/transactions}, + * {@code GET /v2/items}), whose responses carry a {@code next} query string. + */ +public final class Cursors { + + private Cursors() { + } + + /** + * Extracts the URL-decoded {@code after} parameter from a {@code next} query string. + * + * @param next String - the {@code next} value of a cursor-paginated response + * @return the cursor, or null if {@code next} is null or has no {@code after} parameter + */ + public static String parseAfterCursor(String next) { + if (next == null || next.isEmpty()) { + return null; + } + String query = next; + int questionMark = query.indexOf('?'); + if (questionMark >= 0) { + query = query.substring(questionMark + 1); + } + int fragment = query.indexOf('#'); + if (fragment >= 0) { + query = query.substring(0, fragment); + } + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + String key = eq >= 0 ? pair.substring(0, eq) : pair; + if ("after".equals(key)) { + String value = eq >= 0 ? pair.substring(eq + 1) : ""; + if (value.isEmpty()) { + return null; + } + try { + return URLDecoder.decode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + } + return null; + } +} diff --git a/src/test/java/ai/pluggy/client/unit/ItemsV2Test.java b/src/test/java/ai/pluggy/client/unit/ItemsV2Test.java new file mode 100644 index 0000000..8d9d224 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/ItemsV2Test.java @@ -0,0 +1,139 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.pluggy.client.PluggyClient; +import ai.pluggy.client.request.ItemsCursorSearchRequest; +import ai.pluggy.client.response.ErrorResponse; +import ai.pluggy.client.response.ItemResponse; +import ai.pluggy.client.response.ItemsCursorResponse; +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import retrofit2.Response; + +public class ItemsV2Test { + + private PluggyClient client; + private Request lastRequest; + private int responseCode; + private String responseJson; + + @BeforeEach + void setUp() { + responseCode = 200; + PluggyClient.PluggyClientBuilder builder = PluggyClient.builder() + .clientIdAndSecret("client-id", "client-secret") + .noAuthInterceptor(); + // answer every request with a canned body, so no request leaves the process + builder.okHttpClientBuilder().addInterceptor(chain -> { + lastRequest = chain.request(); + return new okhttp3.Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(responseCode) + .message(responseCode == 200 ? "OK" : "Error") + .body(ResponseBody.create(responseJson, MediaType.parse("application/json"))) + .build(); + }); + client = builder.build(); + } + + @Test + void getItems_parsesPageAndCursor() throws IOException { + responseJson = "{\"results\":[" + + "{\"id\":\"item-2\",\"clientUserId\":\"user-1\",\"status\":\"UPDATED\"," + + "\"connector\":{\"id\":201,\"name\":\"Bank\"}}," + + "{\"id\":\"item-1\",\"clientUserId\":\"user-1\",\"status\":\"LOGIN_ERROR\"}]," + + "\"next\":\"?clientUserId=user-1&after=abc%2B123%3D%3D\"}"; + + Response response = client.service().getItems().execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/v2/items", lastRequest.url().encodedPath()); + assertEquals(0, lastRequest.url().querySize()); + + ItemsCursorResponse page = response.body(); + assertNotNull(page); + assertEquals(2, page.getResults().size()); + ItemResponse newest = page.getResults().get(0); + assertEquals("item-2", newest.getId()); + assertEquals("user-1", newest.getClientUserId()); + assertEquals(201, newest.getConnector().getId()); + assertEquals("abc+123==", page.getNextCursor()); + assertTrue(page.hasNext()); + } + + @Test + void getItems_withFilters_sendsOnlyWhitelistedParams() throws IOException { + responseJson = "{\"results\":[],\"next\":null}"; + + ItemsCursorSearchRequest request = new ItemsCursorSearchRequest() + .clientUserId("user 1") + .connectorId(201) + .after("abc+123=="); + client.service().getItems(request).execute(); + + assertEquals("/v2/items", lastRequest.url().encodedPath()); + assertEquals("user 1", lastRequest.url().queryParameter("clientUserId")); + assertEquals("201", lastRequest.url().queryParameter("connectorId")); + // the decoded cursor is re-encoded on the wire and round-trips intact + assertEquals("abc+123==", lastRequest.url().queryParameter("after")); + assertEquals(3, lastRequest.url().querySize()); + assertNull(lastRequest.url().queryParameter("page")); + assertNull(lastRequest.url().queryParameter("pageSize")); + } + + @Test + void getItems_lastPage_hasNoCursor() throws IOException { + responseJson = "{\"results\":[{\"id\":\"item-1\"}],\"next\":null}"; + + ItemsCursorResponse page = client.service().getItems().execute().body(); + + assertNotNull(page); + assertEquals(1, page.getResults().size()); + assertNull(page.getNext()); + assertNull(page.getNextCursor()); + assertFalse(page.hasNext()); + } + + @Test + void getItems_featureNotEnabled_parsesErrorCode() throws IOException { + responseCode = 403; + responseJson = "{\"message\":\"This client is not enabled to list its items.\"," + + "\"code\":403,\"codeDescription\":\"LIST_ITEMS_FEATURE_NOT_ENABLED\"}"; + + Response response = client.service().getItems().execute(); + + assertFalse(response.isSuccessful()); + assertEquals(403, response.code()); + ErrorResponse error = client.parseError(response); + assertEquals(403, error.getCode()); + assertEquals("LIST_ITEMS_FEATURE_NOT_ENABLED", error.getCodeDescription()); + assertEquals("This client is not enabled to list its items.", error.getMessage()); + } + + @Test + void itemsCursorSearchRequest_validatesInput() { + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i < 256; i++) { + tooLong.append('x'); + } + assertThrows(IllegalArgumentException.class, + () -> new ItemsCursorSearchRequest().clientUserId(tooLong.toString())); + assertThrows(IllegalArgumentException.class, + () -> new ItemsCursorSearchRequest().connectorId(-1)); + assertThrows(IllegalArgumentException.class, + () -> new ItemsCursorSearchRequest().after(null)); + assertEquals(0, new ItemsCursorSearchRequest().connectorId(0).getConnectorId()); + } +} From 359e252cc8821dbc213820080195647b640f1e18 Mon Sep 17 00:00:00 2001 From: Gabriel Pan Gantes Date: Mon, 5 Oct 2026 08:06:54 +0200 Subject: [PATCH 3/3] feat: sync response models with the API spec and add getItemScr Adds the fields the API returns that the models were missing (Item, CreditData, Transaction, Connector, Investment, Identity, Bill, PaymentData) and GET /items/{id}/scr (opt-in, SCR feature required). IdentityResponse.identityRelations is deprecated: the API sends the list as relations, so it was never filled. --- .../ai/pluggy/client/PluggyApiService.java | 32 +++ .../client/response/AdditionalCard.java | 19 ++ .../java/ai/pluggy/client/response/Bill.java | 2 + .../pluggy/client/response/BillPayment.java | 36 +++ .../ai/pluggy/client/response/Connector.java | 7 + .../ai/pluggy/client/response/CreditData.java | 7 + .../client/response/EconomicActivity.java | 22 ++ .../IdentityFinancialRelationshipAccount.java | 34 +++ .../IdentityFinancialRelationships.java | 40 +++ .../client/response/IdentityProcurator.java | 34 +++ .../response/IdentityQualifications.java | 39 +++ .../client/response/IdentityResponse.java | 18 ++ .../client/response/InformedIncome.java | 27 ++ .../client/response/InformedPatrimony.java | 27 ++ .../client/response/InformedRevenue.java | 28 ++ .../ai/pluggy/client/response/Investment.java | 14 + .../response/InvestmentCouponPayment.java | 25 ++ .../client/response/InvestmentDebtor.java | 19 ++ .../pluggy/client/response/ItemResponse.java | 9 + .../client/response/ItemUserAction.java | 31 +++ .../client/response/PaycheckBankLink.java | 33 +++ .../pluggy/client/response/PaymentData.java | 7 + .../client/response/PortabilityReceived.java | 33 +++ .../client/response/ScrAdditionalInfo.java | 25 ++ .../pluggy/client/response/ScrDatabase.java | 48 ++++ .../pluggy/client/response/ScrGuarantee.java | 22 ++ .../client/response/ScrMaturityBalances.java | 76 ++++++ .../pluggy/client/response/ScrOperation.java | 42 +++ .../pluggy/client/response/ScrResponse.java | 33 +++ .../client/response/ScrValidationMessage.java | 22 ++ .../pluggy/client/response/Transaction.java | 5 + .../ai/pluggy/client/unit/ItemScrTest.java | 161 +++++++++++ .../client/unit/ResponseFieldsTest.java | 251 ++++++++++++++++++ 33 files changed, 1228 insertions(+) create mode 100644 src/main/java/ai/pluggy/client/response/AdditionalCard.java create mode 100644 src/main/java/ai/pluggy/client/response/BillPayment.java create mode 100644 src/main/java/ai/pluggy/client/response/EconomicActivity.java create mode 100644 src/main/java/ai/pluggy/client/response/IdentityFinancialRelationshipAccount.java create mode 100644 src/main/java/ai/pluggy/client/response/IdentityFinancialRelationships.java create mode 100644 src/main/java/ai/pluggy/client/response/IdentityProcurator.java create mode 100644 src/main/java/ai/pluggy/client/response/IdentityQualifications.java create mode 100644 src/main/java/ai/pluggy/client/response/InformedIncome.java create mode 100644 src/main/java/ai/pluggy/client/response/InformedPatrimony.java create mode 100644 src/main/java/ai/pluggy/client/response/InformedRevenue.java create mode 100644 src/main/java/ai/pluggy/client/response/InvestmentCouponPayment.java create mode 100644 src/main/java/ai/pluggy/client/response/InvestmentDebtor.java create mode 100644 src/main/java/ai/pluggy/client/response/ItemUserAction.java create mode 100644 src/main/java/ai/pluggy/client/response/PaycheckBankLink.java create mode 100644 src/main/java/ai/pluggy/client/response/PortabilityReceived.java create mode 100644 src/main/java/ai/pluggy/client/response/ScrAdditionalInfo.java create mode 100644 src/main/java/ai/pluggy/client/response/ScrDatabase.java create mode 100644 src/main/java/ai/pluggy/client/response/ScrGuarantee.java create mode 100644 src/main/java/ai/pluggy/client/response/ScrMaturityBalances.java create mode 100644 src/main/java/ai/pluggy/client/response/ScrOperation.java create mode 100644 src/main/java/ai/pluggy/client/response/ScrResponse.java create mode 100644 src/main/java/ai/pluggy/client/response/ScrValidationMessage.java create mode 100644 src/test/java/ai/pluggy/client/unit/ItemScrTest.java create mode 100644 src/test/java/ai/pluggy/client/unit/ResponseFieldsTest.java diff --git a/src/main/java/ai/pluggy/client/PluggyApiService.java b/src/main/java/ai/pluggy/client/PluggyApiService.java index adf79da..3357fac 100644 --- a/src/main/java/ai/pluggy/client/PluggyApiService.java +++ b/src/main/java/ai/pluggy/client/PluggyApiService.java @@ -96,6 +96,38 @@ Call updateItemSendMfa(@Path("id") String itemId, Call getItemResources(@Path("id") String itemId, @QueryMap ItemResourcesSearchRequest itemResourcesSearchRequest); + /** + * Retrieve the SCR (Bacen's Sistema de Informações de Crédito) for the document behind an item, + * for the last 4 available base dates. The response is Bacen's own payload, forwarded unchanged. + * + *

Opt-in: requires the SCR feature to be enabled for your team (ask Pluggy support); otherwise + * 403 SCR_FEATURE_NOT_ENABLED (see {@link ErrorResponse#getCodeDescription()}). Only available for + * Open Finance items with a known CPF/CNPJ (otherwise 422 SCR_ITEM_NOT_SUPPORTED). + * + * @param itemId item primary identifier + */ + @GET("/items/{id}/scr") + Call getItemScr(@Path("id") String itemId); + + /** + * Retrieve the SCR (Bacen's Sistema de Informações de Crédito) for the document behind an item, + * for a range of base dates. Base dates are months, and Bacen consolidates each one with a few + * months of delay, so the current month returns nothing. + * + *

Opt-in: requires the SCR feature to be enabled for your team (ask Pluggy support); otherwise + * 403 SCR_FEATURE_NOT_ENABLED (see {@link ErrorResponse#getCodeDescription()}). Only available for + * Open Finance items with a known CPF/CNPJ (otherwise 422 SCR_ITEM_NOT_SUPPORTED). + * + * @param itemId item primary identifier + * @param from first base date to consult, as YYYYMM (e.g. {@code 202604}); null to default to 3 + * base dates before {@code to} + * @param to last base date to consult, as YYYYMM (e.g. {@code 202607}); null to default to 2 + * months before the current one + */ + @GET("/items/{id}/scr") + Call getItemScr(@Path("id") String itemId, @Query("from") String from, + @Query("to") String to); + @GET("/accounts") Call getAccounts(@Query("itemId") String itemId); diff --git a/src/main/java/ai/pluggy/client/response/AdditionalCard.java b/src/main/java/ai/pluggy/client/response/AdditionalCard.java new file mode 100644 index 0000000..4042b3d --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/AdditionalCard.java @@ -0,0 +1,19 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Additional credit card associated with the main one. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdditionalCard { + + /** Number of the additional credit card. */ + String number; +} diff --git a/src/main/java/ai/pluggy/client/response/Bill.java b/src/main/java/ai/pluggy/client/response/Bill.java index a6a3507..192b146 100644 --- a/src/main/java/ai/pluggy/client/response/Bill.java +++ b/src/main/java/ai/pluggy/client/response/Bill.java @@ -24,4 +24,6 @@ public class Bill { List financeCharges; Date createdAt; Date updatedAt; + /** Payments associated to the bill. */ + List payments; } diff --git a/src/main/java/ai/pluggy/client/response/BillPayment.java b/src/main/java/ai/pluggy/client/response/BillPayment.java new file mode 100644 index 0000000..bbbf972 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/BillPayment.java @@ -0,0 +1,36 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Payment associated to a credit card bill. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BillPayment { + + /** Primary identifier. */ + String id; + + /** Payment value type: {@code INSTALLMENT_PAYMENT}, {@code FULL_PAYMENT} or {@code OTHER_PAYMENT}. */ + String valueType; + + /** Date when the payment was made. */ + Date paymentDate; + + /** Payment mode used: {@code DEBIT_ACCOUNT}, {@code BANK_SLIP}, {@code PAYROLL_DEDUCTION}, {@code PIX}, or null. */ + String paymentMode; + + /** Payment amount. */ + Double amount; + + /** Currency code of the payment (e.g. {@code BRL}). */ + String currencyCode; +} diff --git a/src/main/java/ai/pluggy/client/response/Connector.java b/src/main/java/ai/pluggy/client/response/Connector.java index 75df458..9f0f69c 100644 --- a/src/main/java/ai/pluggy/client/response/Connector.java +++ b/src/main/java/ai/pluggy/client/response/Connector.java @@ -35,4 +35,11 @@ public class Connector { Boolean supportsScheduledPayments; Boolean supportsSmartTransfers; Boolean supportsBoletoManagement; + /** Whether the connector supports automatic Pix. */ + Boolean supportsAutomaticPix; + /** + * Which sub-products the institution serves, in the Open Finance directory's vocabulary (e.g. + * {@code INVESTMENTS:TREASURE_TITLES}). Absent for direct connectors. + */ + List productCoverage; } \ No newline at end of file diff --git a/src/main/java/ai/pluggy/client/response/CreditData.java b/src/main/java/ai/pluggy/client/response/CreditData.java index ebf4284..3cabadc 100644 --- a/src/main/java/ai/pluggy/client/response/CreditData.java +++ b/src/main/java/ai/pluggy/client/response/CreditData.java @@ -25,4 +25,11 @@ public class CreditData { HolderType holderType; CreditCardStatus status; List disaggregatedCreditLimits; + /** Additional credit cards associated with the main one. */ + List additionalCards; + /** + * Whether the credit limit can be adjusted (e.g. the customer can request an increase). Only + * returned when reported by the institution. + */ + Boolean isLimitFlexible; } diff --git a/src/main/java/ai/pluggy/client/response/EconomicActivity.java b/src/main/java/ai/pluggy/client/response/EconomicActivity.java new file mode 100644 index 0000000..5c21786 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/EconomicActivity.java @@ -0,0 +1,22 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * CNAE code describing one of the business's economic activities. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EconomicActivity { + + /** CNAE code (7 digits, including leading zeros). */ + String code; + + /** Whether this is the main economic activity. */ + Boolean isMain; +} diff --git a/src/main/java/ai/pluggy/client/response/IdentityFinancialRelationshipAccount.java b/src/main/java/ai/pluggy/client/response/IdentityFinancialRelationshipAccount.java new file mode 100644 index 0000000..72fa455 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/IdentityFinancialRelationshipAccount.java @@ -0,0 +1,34 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Account of the client with valid consent. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IdentityFinancialRelationshipAccount { + + /** COMPE code of the account. */ + String compeCode; + + /** Branch code of the account. */ + String branchCode; + + /** Number of the account. */ + String number; + + /** Check digit of the account. */ + String checkDigit; + + /** Type of the account. */ + String type; + + /** Subtype of the account. */ + String subtype; +} diff --git a/src/main/java/ai/pluggy/client/response/IdentityFinancialRelationships.java b/src/main/java/ai/pluggy/client/response/IdentityFinancialRelationships.java new file mode 100644 index 0000000..c8132a5 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/IdentityFinancialRelationships.java @@ -0,0 +1,40 @@ +package ai.pluggy.client.response; + +import java.util.Date; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Information that allows institutions to assess the client's risk profile and economic-financial capacity. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IdentityFinancialRelationships { + + /** Date when the relationship with the institution started. */ + Date startDate; + + /** Products and services the client consumes. */ + List productsServicesType; + + /** Additional info about the products and services, when {@code productsServicesType} includes {@code OUTROS}. */ + String productsServicesTypeAdditionalInfo; + + /** Procurators of the client. */ + List procurators; + + /** Accounts of the client with valid consent. Only accounts with explicit user consent are returned. */ + List accounts; + + /** Salary portabilities received from the client's previous paycheck banks (PF-only). */ + List portabilitiesReceived; + + /** Paycheck-bank links to employers, active or formerly active (PF-only). */ + List paychecksBankLink; +} diff --git a/src/main/java/ai/pluggy/client/response/IdentityProcurator.java b/src/main/java/ai/pluggy/client/response/IdentityProcurator.java new file mode 100644 index 0000000..376e160 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/IdentityProcurator.java @@ -0,0 +1,34 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Legal representative or procurator of the client. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IdentityProcurator { + + /** Type of relationship: {@code REPRESENTANTE_LEGAL} or {@code PROCURADOR}. */ + String type; + + /** CPF of the procurator. For business links it may hold a CPF or a CNPJ; prefer {@link #documentNumber}. */ + String cpfNumber; + + /** Document number of the procurator (CPF or CNPJ). */ + String documentNumber; + + /** Type of document carried by {@link #documentNumber}: {@code CPF} or {@code CNPJ}. */ + String documentType; + + /** Civil name of the procurator (company name for business procurators). */ + String civilName; + + /** Social name of the procurator, if any. */ + String socialName; +} diff --git a/src/main/java/ai/pluggy/client/response/IdentityQualifications.java b/src/main/java/ai/pluggy/client/response/IdentityQualifications.java new file mode 100644 index 0000000..da0e6b2 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/IdentityQualifications.java @@ -0,0 +1,39 @@ +package ai.pluggy.client.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Qualification data of the client: occupation, informed income/patrimony and, for businesses, economic activities and revenue. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IdentityQualifications { + + /** CNPJ of the company. */ + String companyCnpj; + + /** Occupation code: {@code RECEITA_FEDERAL}, {@code CBO} or {@code OUTRO}. */ + String occupationCode; + + /** Occupation description (list code for {@code RECEITA_FEDERAL}/{@code CBO}, free text for {@code OUTRO}). */ + String occupationDescription; + + /** Informed income. */ + InformedIncome informedIncome; + + /** Informed patrimony. */ + InformedPatrimony informedPatrimony; + + /** CNAE codes describing the business's economic activities. */ + List economicActivities; + + /** Revenue informed by the business. */ + InformedRevenue informedRevenue; +} diff --git a/src/main/java/ai/pluggy/client/response/IdentityResponse.java b/src/main/java/ai/pluggy/client/response/IdentityResponse.java index a2b2f63..857b1d7 100644 --- a/src/main/java/ai/pluggy/client/response/IdentityResponse.java +++ b/src/main/java/ai/pluggy/client/response/IdentityResponse.java @@ -28,6 +28,11 @@ public class IdentityResponse { List

addresses; List phoneNumbers; List emails; + /** + * @deprecated the API sends this list as {@code relations}, so this field is never filled. Use + * {@link #getRelations()}. + */ + @Deprecated List identityRelations; Date createdAt; Date updatedAt; @@ -55,4 +60,17 @@ public class IdentityResponse { List businessOtherDocuments; /** CNPJs of the financial institutions responsible for the customer cadastro. */ List companiesCnpj; + /** Establishment code (only for PAYMENT_ACCOUNT connectors). */ + String establishmentCode; + /** Name of the establishment (only for PAYMENT_ACCOUNT connectors). */ + String establishmentName; + /** Names related to the account owner (mother, father, spouse). */ + List relations; + /** + * Information to assess the client's risk profile and economic-financial capacity: relationship + * start date, products consumed, procurators, consented accounts and paycheck-bank links. + */ + IdentityFinancialRelationships financialRelationships; + /** Occupation, informed income/patrimony and, for businesses, economic activities and revenue. */ + IdentityQualifications qualifications; } diff --git a/src/main/java/ai/pluggy/client/response/InformedIncome.java b/src/main/java/ai/pluggy/client/response/InformedIncome.java new file mode 100644 index 0000000..89a450f --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/InformedIncome.java @@ -0,0 +1,27 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Income informed by the client. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InformedIncome { + + /** Frequency: {@code DIARIA}, {@code SEMANAL}, {@code QUINZENAL}, {@code MENSAL}, {@code BIMESTRAL}, {@code TRIMESTRAL}, {@code SEMESTRAL}, {@code ANUAL} or {@code OUTROS}. */ + String frequency; + + /** Amount of the informed income. */ + Double amount; + + /** Date when the income was informed. */ + Date date; +} diff --git a/src/main/java/ai/pluggy/client/response/InformedPatrimony.java b/src/main/java/ai/pluggy/client/response/InformedPatrimony.java new file mode 100644 index 0000000..5456fb4 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/InformedPatrimony.java @@ -0,0 +1,27 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Patrimony informed by the client. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InformedPatrimony { + + /** Amount of the informed patrimony. */ + Double amount; + + /** Year of the patrimony. */ + Integer year; + + /** Reference date of the patrimony (business path only). */ + Date date; +} diff --git a/src/main/java/ai/pluggy/client/response/InformedRevenue.java b/src/main/java/ai/pluggy/client/response/InformedRevenue.java new file mode 100644 index 0000000..55bd01b --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/InformedRevenue.java @@ -0,0 +1,28 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Revenue (faturamento) informed by the business. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InformedRevenue { + + /** Amount of the informed revenue. */ + Double amount; + + /** Frequency: {@code DAILY}, {@code WEEKLY}, {@code BIWEEKLY}, {@code MONTHLY}, {@code BIMONTHLY}, {@code QUARTERLY}, {@code SEMIANNUAL}, {@code ANNUAL} or {@code OTHER}. */ + String frequency; + + /** Free-text complement to the frequency, when it is {@code OTHER}. */ + String frequencyAdditionalInfo; + + /** Reference year of the revenue. */ + Integer year; +} diff --git a/src/main/java/ai/pluggy/client/response/Investment.java b/src/main/java/ai/pluggy/client/response/Investment.java index 16b6d3b..b0e8f8c 100644 --- a/src/main/java/ai/pluggy/client/response/Investment.java +++ b/src/main/java/ai/pluggy/client/response/Investment.java @@ -37,6 +37,10 @@ public class Investment { Double taxes; Double taxes2; Double amountOriginal; + /** + * Profit/Loss to date over the investment. The API sends a number; kept as String (Gson reads + * the number into it) so existing callers don't break. Parse with {@code Double.valueOf}. + */ String amountProfit = null; Double amountWithdrawal; String issuer; @@ -63,4 +67,14 @@ public class Investment { InvestmentMetadata metadata; String providerId; InvestmentInstitution institution; + /** The date when the grace period ends (fixed-income investments only). */ + Date gracePeriodDate; + /** B3 lot/price conversion factor (variable income). */ + Double priceFactor; + /** Whether the product is tax-exempt (LCI, LCA, CRI, CRA, incentivized debentures). */ + Boolean taxExempt; + /** Coupon-payment schedule for coupon-bearing fixed income / Treasury bonds. */ + InvestmentCouponPayment couponPayment; + /** Underlying debtor of receivables-backed paper (CRI / CRA). */ + InvestmentDebtor debtor; } diff --git a/src/main/java/ai/pluggy/client/response/InvestmentCouponPayment.java b/src/main/java/ai/pluggy/client/response/InvestmentCouponPayment.java new file mode 100644 index 0000000..b599db5 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/InvestmentCouponPayment.java @@ -0,0 +1,25 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Coupon-payment schedule for coupon-bearing fixed income / Treasury bonds. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InvestmentCouponPayment { + + /** Whether the paper pays periodic coupons. */ + Boolean hasCoupon; + + /** Frequency of coupon payments: {@code MONTHLY}, {@code QUARTERLY}, {@code SEMESTERLY}, {@code YEARLY} or {@code IRREGULAR}. */ + String periodicity; + + /** Free-text detail when periodicity is {@code IRREGULAR}. */ + String additionalInfo; +} diff --git a/src/main/java/ai/pluggy/client/response/InvestmentDebtor.java b/src/main/java/ai/pluggy/client/response/InvestmentDebtor.java new file mode 100644 index 0000000..c0a6ce9 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/InvestmentDebtor.java @@ -0,0 +1,19 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Underlying debtor of receivables-backed paper (CRI / CRA). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InvestmentDebtor { + + /** Name of the underlying debtor. */ + String name; +} diff --git a/src/main/java/ai/pluggy/client/response/ItemResponse.java b/src/main/java/ai/pluggy/client/response/ItemResponse.java index db09786..ace5626 100644 --- a/src/main/java/ai/pluggy/client/response/ItemResponse.java +++ b/src/main/java/ai/pluggy/client/response/ItemResponse.java @@ -42,4 +42,13 @@ public class ItemResponse { * while the resource list has not been read yet ({@code resourcesCollectedAt} is null). */ Boolean hasResourcesPendingAuthorization; + + /** Consent expiration date (Open Finance items). */ + Date consentExpiresAt; + + /** Date of the next auto-sync, or null if auto-sync is disabled for this item. */ + Date nextAutoSyncAt; + + /** User action the item is waiting on (e.g. a QR code to scan), when there is one. */ + ItemUserAction userAction; } diff --git a/src/main/java/ai/pluggy/client/response/ItemUserAction.java b/src/main/java/ai/pluggy/client/response/ItemUserAction.java new file mode 100644 index 0000000..631cd09 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ItemUserAction.java @@ -0,0 +1,31 @@ +package ai.pluggy.client.response; + +import java.util.Date; +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * User action the item is waiting on (for example, scanning a QR code or authorizing access in the institution's app). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ItemUserAction { + + /** Instructions related to the user action. */ + String instructions; + + /** Additional information related to the user action, for example in a device authorization flow. */ + Map attributes; + + /** User action expiration date. */ + Date expiresAt; + + /** Kind of action the user has to take: {@code qr} or {@code authorize-access}. */ + String type; +} diff --git a/src/main/java/ai/pluggy/client/response/PaycheckBankLink.java b/src/main/java/ai/pluggy/client/response/PaycheckBankLink.java new file mode 100644 index 0000000..b2de7b4 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/PaycheckBankLink.java @@ -0,0 +1,33 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Paycheck-bank (banco-folha) link to an employer, active or formerly active. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaycheckBankLink { + + /** Employer name as registered when the paycheck account was opened. */ + String employerName; + + /** Employer document (CPF or CNPJ). */ + String employerDocument; + + /** CNPJ of the institution providing the paycheck service. */ + String paycheckBankCnpj; + + /** ISPB of the institution providing the paycheck service. */ + String paycheckBankIspb; + + /** Date the paycheck account was opened. */ + Date accountOpeningDate; +} diff --git a/src/main/java/ai/pluggy/client/response/PaymentData.java b/src/main/java/ai/pluggy/client/response/PaymentData.java index c95f2c3..e39cd97 100644 --- a/src/main/java/ai/pluggy/client/response/PaymentData.java +++ b/src/main/java/ai/pluggy/client/response/PaymentData.java @@ -10,4 +10,11 @@ public class PaymentData { String referenceNumber; String reason; BoletoMetadata boletoMetadata; + /** + * Authentication code of the payment receipt, as printed by the institution on the proof of + * payment. Can be present for any payment method. + */ + String authenticationCode; + /** String submitted by the receiver when generating the payment request. */ + String receiverReferenceId; } diff --git a/src/main/java/ai/pluggy/client/response/PortabilityReceived.java b/src/main/java/ai/pluggy/client/response/PortabilityReceived.java new file mode 100644 index 0000000..e40f3bb --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/PortabilityReceived.java @@ -0,0 +1,33 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Salary portability received by the institution from a previous paycheck bank (banco-folha). Approved requests only; an entry does not guarantee an active link. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortabilityReceived { + + /** Employer name as received in the portability message. */ + String employerName; + + /** Employer document (CPF or CNPJ). */ + String employerDocument; + + /** CNPJ of the bank that holds the paycheck account. */ + String paycheckBankDetainerCnpj; + + /** ISPB of the bank that holds the paycheck account. */ + String paycheckBankDetainerIspb; + + /** Date the portability was approved. */ + Date portabilityApprovalDate; +} diff --git a/src/main/java/ai/pluggy/client/response/ScrAdditionalInfo.java b/src/main/java/ai/pluggy/client/response/ScrAdditionalInfo.java new file mode 100644 index 0000000..ba944b5 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ScrAdditionalInfo.java @@ -0,0 +1,25 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Complementary information reported for an SCR operation group. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScrAdditionalInfo { + + /** Type of the complementary information. */ + String tp; + + /** Code of the complementary information. */ + String cd; + + /** Number of operations grouped under this entry. */ + Integer qtd; +} diff --git a/src/main/java/ai/pluggy/client/response/ScrDatabase.java b/src/main/java/ai/pluggy/client/response/ScrDatabase.java new file mode 100644 index 0000000..34fa078 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ScrDatabase.java @@ -0,0 +1,48 @@ +package ai.pluggy.client.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One consulted SCR base date, and what the SCR holds for the client in it. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScrDatabase { + + /** The base date, as YYYYMM (numeric). */ + Integer dtb; + + /** Bacen's message for this base date, when it has one. */ + String msg; + + /** Percentage of the expected 3040 documents already incorporated by Bacen for this base date. */ + String docProc; + + /** Percentage of the expected operation volume already accepted for this base date. */ + String volProc; + + /** Number of financial institutions where the client has operations. */ + Integer qtdIfs; + + /** Number of financial conglomerates where the client has operations. */ + Integer qtdCongFinc; + + /** Start of the client's relationship with the national financial system. */ + String dtbIniRel; + + /** Co-obligation assumed by the client in credit assignments, in BRL. */ + Double coobAss; + + /** Co-obligation received in credit assignments, in BRL. */ + Double coobRec; + + /** Operation groups reported for this base date. */ + List lsOp; +} diff --git a/src/main/java/ai/pluggy/client/response/ScrGuarantee.java b/src/main/java/ai/pluggy/client/response/ScrGuarantee.java new file mode 100644 index 0000000..153c7a9 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ScrGuarantee.java @@ -0,0 +1,22 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Guarantee backing an SCR operation group. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScrGuarantee { + + /** Bacen's code for the guarantee type. */ + String tp; + + /** Number of operations grouped under this type. */ + Integer qtd; +} diff --git a/src/main/java/ai/pluggy/client/response/ScrMaturityBalances.java b/src/main/java/ai/pluggy/client/response/ScrMaturityBalances.java new file mode 100644 index 0000000..2a1785b --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ScrMaturityBalances.java @@ -0,0 +1,76 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Balance of an SCR operation group split across Bacen's maturity vertices, in BRL. Each field is a vertex code as defined by Bacen's DOC3040 reference; only the vertices that carry a value are present (others are null). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScrMaturityBalances { + + Double v20; + + Double v40; + + Double v60; + + Double v80; + + Double v110; + + Double v120; + + Double v130; + + Double v140; + + Double v150; + + Double v160; + + Double v165; + + Double v170; + + Double v175; + + Double v180; + + Double v190; + + Double v199; + + Double v205; + + Double v210; + + Double v220; + + Double v230; + + Double v240; + + Double v245; + + Double v250; + + Double v255; + + Double v260; + + Double v270; + + Double v280; + + Double v290; + + Double v310; + + Double v320; +} diff --git a/src/main/java/ai/pluggy/client/response/ScrOperation.java b/src/main/java/ai/pluggy/client/response/ScrOperation.java new file mode 100644 index 0000000..ab4c643 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ScrOperation.java @@ -0,0 +1,42 @@ +package ai.pluggy.client.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A group of SCR credit operations, aggregated by modality, source of funds, index and exchange variation. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScrOperation { + + /** Bacen's code for the operation modality. */ + String mod; + + /** Bacen's code for the source of funds. */ + String oriRec; + + /** Bacen's code for the reference rate or index. */ + String indx; + + /** Bacen's code for the exchange rate variation. */ + String varCamb; + + /** Present when the operation is under dispute: {@code D}, {@code J} or {@code JD}. */ + String subJDisc; + + /** Balance of the group split across Bacen's maturity vertices. */ + ScrMaturityBalances resVenc; + + /** Guarantees backing the operations in this group. */ + List lsGar; + + /** Complementary information reported for this group. */ + List lsInfAd; +} diff --git a/src/main/java/ai/pluggy/client/response/ScrResponse.java b/src/main/java/ai/pluggy/client/response/ScrResponse.java new file mode 100644 index 0000000..eef8179 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ScrResponse.java @@ -0,0 +1,33 @@ +package ai.pluggy.client.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Bacen's SCR (Sistema de Informações de Crédito) response, forwarded exactly as the Banco Central returns it: field names and codes are not renamed or normalised. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScrResponse { + + /** The base dates consulted (e.g. {@code 202604 a 202607}). */ + String dtbConsult; + + /** The consulted document: the CPF for an individual, or the 8-digit CNPJ root for a company. */ + String cdCli; + + /** Type of client: {@code "1"} for an individual, {@code "2"} for a legal entity. */ + String tpCli; + + /** One entry per consulted base date. A base date with no data is still listed, with no operations. */ + List lsDtb; + + /** Validation messages raised by Bacen for this request. */ + List listaDeMensagensDeValidacao; +} diff --git a/src/main/java/ai/pluggy/client/response/ScrValidationMessage.java b/src/main/java/ai/pluggy/client/response/ScrValidationMessage.java new file mode 100644 index 0000000..24b7a3f --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ScrValidationMessage.java @@ -0,0 +1,22 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Validation message raised by Bacen for an SCR request. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScrValidationMessage { + + /** Message code. */ + String codigo; + + /** Message text. */ + String mensagem; +} diff --git a/src/main/java/ai/pluggy/client/response/Transaction.java b/src/main/java/ai/pluggy/client/response/Transaction.java index 57ac323..0a0f9af 100644 --- a/src/main/java/ai/pluggy/client/response/Transaction.java +++ b/src/main/java/ai/pluggy/client/response/Transaction.java @@ -24,4 +24,9 @@ public class Transaction { String operationType; String operationTypeAdditionalInfo; String providerId; + /** + * Sequential position of the transaction within the same day, used to preserve ordering when + * multiple transactions share the same date. + */ + Integer order; } diff --git a/src/test/java/ai/pluggy/client/unit/ItemScrTest.java b/src/test/java/ai/pluggy/client/unit/ItemScrTest.java new file mode 100644 index 0000000..939d393 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/ItemScrTest.java @@ -0,0 +1,161 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import ai.pluggy.client.PluggyClient; +import ai.pluggy.client.response.ErrorResponse; +import ai.pluggy.client.response.ScrDatabase; +import ai.pluggy.client.response.ScrOperation; +import ai.pluggy.client.response.ScrResponse; +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import retrofit2.Response; + +public class ItemScrTest { + + private static final String SCR_JSON = "{\"dtbConsult\":\"202604 a 202607\"," + + "\"cdCli\":\"12345678901\",\"tpCli\":\"1\"," + + "\"lsDtb\":[{\"dtb\":202607,\"docProc\":\"99.5\",\"volProc\":\"98.1\",\"qtdIfs\":2," + + "\"qtdCongFinc\":1,\"dtbIniRel\":\"2010-03-01\",\"coobAss\":0,\"coobRec\":150.25," + + "\"lsOp\":[{\"mod\":\"0203\",\"oriRec\":\"0100\",\"indx\":\"11\",\"varCamb\":\"790\"," + + "\"subJDisc\":\"D\",\"resVenc\":{\"v110\":1000.5,\"v120\":250,\"v320\":10}," + + "\"lsGar\":[{\"tp\":\"0101\",\"qtd\":1}]," + + "\"lsInfAd\":[{\"tp\":\"01\",\"cd\":\"0001\",\"qtd\":3}]}]}," + + "{\"dtb\":202604,\"msg\":\"Base date not available for consultation\"}]," + + "\"listaDeMensagensDeValidacao\":[{\"codigo\":\"W01\",\"mensagem\":\"Partial data\"}]}"; + + private PluggyClient client; + private Request lastRequest; + private int responseCode; + private String responseJson; + + @BeforeEach + void setUp() { + responseCode = 200; + PluggyClient.PluggyClientBuilder builder = PluggyClient.builder() + .clientIdAndSecret("client-id", "client-secret") + .noAuthInterceptor(); + // answer every request with a canned body, so no request leaves the process + builder.okHttpClientBuilder().addInterceptor(chain -> { + lastRequest = chain.request(); + return new okhttp3.Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(responseCode) + .message(responseCode == 200 ? "OK" : "Error") + .body(ResponseBody.create(responseJson, MediaType.parse("application/json"))) + .build(); + }); + client = builder.build(); + } + + @Test + void getItemScr_withoutRange_sendsNoQueryAndParsesPayload() throws IOException { + responseJson = SCR_JSON; + + Response response = client.service().getItemScr("item-1").execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/items/item-1/scr", lastRequest.url().encodedPath()); + assertEquals(0, lastRequest.url().querySize()); + + ScrResponse scr = response.body(); + assertNotNull(scr); + assertEquals("202604 a 202607", scr.getDtbConsult()); + assertEquals("12345678901", scr.getCdCli()); + assertEquals("1", scr.getTpCli()); + assertEquals(2, scr.getLsDtb().size()); + + ScrDatabase base = scr.getLsDtb().get(0); + assertEquals(202607, base.getDtb()); + assertEquals("99.5", base.getDocProc()); + assertEquals("98.1", base.getVolProc()); + assertEquals(2, base.getQtdIfs()); + assertEquals(1, base.getQtdCongFinc()); + assertEquals("2010-03-01", base.getDtbIniRel()); + assertEquals(0.0, base.getCoobAss()); + assertEquals(150.25, base.getCoobRec()); + + ScrOperation op = base.getLsOp().get(0); + assertEquals("0203", op.getMod()); + assertEquals("0100", op.getOriRec()); + assertEquals("11", op.getIndx()); + assertEquals("790", op.getVarCamb()); + assertEquals("D", op.getSubJDisc()); + assertEquals(1000.5, op.getResVenc().getV110()); + assertEquals(250.0, op.getResVenc().getV120()); + assertEquals(10.0, op.getResVenc().getV320()); + assertNull(op.getResVenc().getV20()); + assertEquals("0101", op.getLsGar().get(0).getTp()); + assertEquals(1, op.getLsGar().get(0).getQtd()); + assertEquals("01", op.getLsInfAd().get(0).getTp()); + assertEquals("0001", op.getLsInfAd().get(0).getCd()); + assertEquals(3, op.getLsInfAd().get(0).getQtd()); + + ScrDatabase empty = scr.getLsDtb().get(1); + assertEquals("Base date not available for consultation", empty.getMsg()); + assertNull(empty.getLsOp()); + + assertEquals("W01", scr.getListaDeMensagensDeValidacao().get(0).getCodigo()); + assertEquals("Partial data", scr.getListaDeMensagensDeValidacao().get(0).getMensagem()); + } + + @Test + void getItemScr_withRange_sendsFromAndTo() throws IOException { + responseJson = SCR_JSON; + + client.service().getItemScr("item-1", "202604", "202607").execute(); + + assertEquals("/items/item-1/scr", lastRequest.url().encodedPath()); + assertEquals(2, lastRequest.url().querySize()); + assertEquals("202604", lastRequest.url().queryParameter("from")); + assertEquals("202607", lastRequest.url().queryParameter("to")); + } + + @Test + void getItemScr_withNullBounds_omitsThem() throws IOException { + responseJson = SCR_JSON; + + client.service().getItemScr("item-1", null, "202607").execute(); + + assertEquals(1, lastRequest.url().querySize()); + assertNull(lastRequest.url().queryParameter("from")); + assertEquals("202607", lastRequest.url().queryParameter("to")); + } + + @Test + void getItemScr_featureNotEnabled_parsesErrorCode() throws IOException { + responseCode = 403; + responseJson = "{\"code\":403,\"codeDescription\":\"SCR_FEATURE_NOT_ENABLED\"," + + "\"message\":\"This client is not enabled to query SCR data.\"}"; + + Response response = client.service().getItemScr("item-1").execute(); + + assertFalse(response.isSuccessful()); + assertEquals(403, response.code()); + ErrorResponse error = client.parseError(response); + assertEquals(403, error.getCode()); + assertEquals("SCR_FEATURE_NOT_ENABLED", error.getCodeDescription()); + assertEquals("This client is not enabled to query SCR data.", error.getMessage()); + } + + @Test + void getItemScr_itemNotSupported_parsesErrorCode() throws IOException { + responseCode = 422; + responseJson = "{\"code\":422,\"codeDescription\":\"SCR_ITEM_NOT_SUPPORTED\"," + + "\"message\":\"SCR is only available for Open Finance items that have a known CPF or CNPJ.\"}"; + + Response response = client.service().getItemScr("item-1").execute(); + + assertEquals(422, response.code()); + assertEquals("SCR_ITEM_NOT_SUPPORTED", client.parseError(response).getCodeDescription()); + } +} diff --git a/src/test/java/ai/pluggy/client/unit/ResponseFieldsTest.java b/src/test/java/ai/pluggy/client/unit/ResponseFieldsTest.java new file mode 100644 index 0000000..0ee94fd --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/ResponseFieldsTest.java @@ -0,0 +1,251 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.pluggy.client.PluggyClient; +import ai.pluggy.client.response.Account; +import ai.pluggy.client.response.Bill; +import ai.pluggy.client.response.BillPayment; +import ai.pluggy.client.response.Connector; +import ai.pluggy.client.response.CreditData; +import ai.pluggy.client.response.IdentityFinancialRelationships; +import ai.pluggy.client.response.IdentityProcurator; +import ai.pluggy.client.response.IdentityQualifications; +import ai.pluggy.client.response.IdentityResponse; +import ai.pluggy.client.response.Investment; +import ai.pluggy.client.response.ItemResponse; +import ai.pluggy.client.response.Transaction; +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Deserialization of response fields that map 1:1 to the API's JSON keys (Gson IDENTITY naming), so + * a casing mismatch would surface here as a null. + */ +public class ResponseFieldsTest { + + private PluggyClient client; + private String responseJson; + + @BeforeEach + void setUp() { + PluggyClient.PluggyClientBuilder builder = PluggyClient.builder() + .clientIdAndSecret("client-id", "client-secret") + .noAuthInterceptor(); + // answer every request with a canned body, so no request leaves the process + builder.okHttpClientBuilder().addInterceptor(chain -> new okhttp3.Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(ResponseBody.create(responseJson, MediaType.parse("application/json"))) + .build()); + client = builder.build(); + } + + @Test + void item_parsesConsentAutoSyncAndUserAction() throws IOException { + responseJson = "{\"id\":\"item-1\",\"consentExpiresAt\":\"2027-01-01T00:00:00.000Z\"," + + "\"nextAutoSyncAt\":\"2026-10-06T08:00:00.000Z\"," + + "\"userAction\":{\"instructions\":\"Scan the QR code\",\"type\":\"qr\"," + + "\"expiresAt\":\"2026-10-05T12:00:00.000Z\",\"attributes\":{\"qr\":\"data:image/png\"}}}"; + + ItemResponse item = client.service().getItem("item-1").execute().body(); + + assertNotNull(item); + assertNotNull(item.getConsentExpiresAt()); + assertNotNull(item.getNextAutoSyncAt()); + assertEquals("Scan the QR code", item.getUserAction().getInstructions()); + assertEquals("qr", item.getUserAction().getType()); + assertNotNull(item.getUserAction().getExpiresAt()); + assertEquals("data:image/png", item.getUserAction().getAttributes().get("qr")); + } + + @Test + void item_withoutAutoSync_hasNullNextAutoSyncAt() throws IOException { + responseJson = "{\"id\":\"item-1\",\"nextAutoSyncAt\":null}"; + + ItemResponse item = client.service().getItem("item-1").execute().body(); + + assertNotNull(item); + assertNull(item.getNextAutoSyncAt()); + assertNull(item.getUserAction()); + } + + @Test + void creditData_parsesAdditionalCardsAndLimitFlexibility() throws IOException { + responseJson = "{\"id\":\"acc-1\",\"creditData\":{\"level\":\"BLACK\"," + + "\"additionalCards\":[{\"number\":\"1234\"},{\"number\":\"5678\"}]," + + "\"isLimitFlexible\":true}}"; + + Account account = client.service().getAccount("acc-1").execute().body(); + + assertNotNull(account); + CreditData creditData = account.getCreditData(); + assertEquals(2, creditData.getAdditionalCards().size()); + assertEquals("5678", creditData.getAdditionalCards().get(1).getNumber()); + assertTrue(creditData.getIsLimitFlexible()); + } + + @Test + void transaction_parsesOrderAndPaymentDataCodes() throws IOException { + responseJson = "{\"id\":\"tx-1\",\"order\":3,\"paymentData\":{\"paymentMethod\":\"PIX\"," + + "\"authenticationCode\":\"E1234567820261005\",\"receiverReferenceId\":\"ref-42\"}}"; + + Transaction transaction = client.service().getTransaction("tx-1").execute().body(); + + assertNotNull(transaction); + assertEquals(3, transaction.getOrder()); + assertEquals("E1234567820261005", transaction.getPaymentData().getAuthenticationCode()); + assertEquals("ref-42", transaction.getPaymentData().getReceiverReferenceId()); + } + + @Test + void connector_parsesProductCoverageAndAutomaticPix() throws IOException { + responseJson = "{\"id\":601,\"name\":\"Bank\",\"supportsAutomaticPix\":true," + + "\"productCoverage\":[\"INVESTMENTS:TREASURE_TITLES\",\"CREDIT_OPERATIONS:INVOICE_FINANCINGS\"]}"; + + Connector connector = client.service().getConnector(601).execute().body(); + + assertNotNull(connector); + assertTrue(connector.getSupportsAutomaticPix()); + assertEquals(2, connector.getProductCoverage().size()); + assertEquals("INVESTMENTS:TREASURE_TITLES", connector.getProductCoverage().get(0)); + } + + @Test + void investment_parsesNewFieldsAndNestedObjects() throws IOException { + responseJson = "{\"id\":\"inv-1\",\"amountProfit\":123.45,\"priceFactor\":1000," + + "\"taxExempt\":true,\"gracePeriodDate\":\"2027-03-01T00:00:00.000Z\"," + + "\"couponPayment\":{\"hasCoupon\":true,\"periodicity\":\"IRREGULAR\"," + + "\"additionalInfo\":\"See prospectus\"}," + + "\"debtor\":{\"name\":\"Agro Company SA\"}}"; + + Investment investment = client.service().getInvestment("inv-1").execute().body(); + + assertNotNull(investment); + assertEquals(123.45, Double.valueOf(investment.getAmountProfit())); + assertEquals(1000.0, investment.getPriceFactor()); + assertTrue(investment.getTaxExempt()); + assertNotNull(investment.getGracePeriodDate()); + assertTrue(investment.getCouponPayment().getHasCoupon()); + assertEquals("IRREGULAR", investment.getCouponPayment().getPeriodicity()); + assertEquals("See prospectus", investment.getCouponPayment().getAdditionalInfo()); + assertEquals("Agro Company SA", investment.getDebtor().getName()); + } + + @Test + void investment_withNullNestedObjects() throws IOException { + responseJson = "{\"id\":\"inv-1\",\"amountProfit\":null,\"couponPayment\":null,\"debtor\":null}"; + + Investment investment = client.service().getInvestment("inv-1").execute().body(); + + assertNotNull(investment); + assertNull(investment.getAmountProfit()); + assertNull(investment.getCouponPayment()); + assertNull(investment.getDebtor()); + } + + @Test + void identity_parsesEstablishmentRelationsAndNestedObjects() throws IOException { + responseJson = "{\"id\":\"id-1\",\"establishmentCode\":\"EST-1\",\"establishmentName\":\"Store\"," + + "\"relations\":[{\"type\":\"Mother\",\"name\":\"Maria Silva\",\"document\":\"00000000000\"}]," + + "\"financialRelationships\":{\"startDate\":\"2015-01-01T00:00:00.000Z\"," + + "\"productsServicesType\":[\"CONTA_DEPOSITO_A_VISTA\",\"OUTROS\"]," + + "\"productsServicesTypeAdditionalInfo\":\"Other product\"," + + "\"procurators\":[{\"type\":\"PROCURADOR\",\"cpfNumber\":\"11111111111\"," + + "\"documentNumber\":\"11111111111\",\"documentType\":\"CPF\",\"civilName\":\"Joao\"," + + "\"socialName\":\"Jo\"}]," + + "\"accounts\":[{\"compeCode\":\"001\",\"branchCode\":\"1234\",\"number\":\"56789\"," + + "\"checkDigit\":\"0\",\"type\":\"CONTA_DEPOSITO_A_VISTA\",\"subtype\":\"INDIVIDUAL\"}]," + + "\"portabilitiesReceived\":[{\"employerName\":\"Acme\",\"employerDocument\":\"22222222000100\"," + + "\"paycheckBankDetainerCnpj\":\"33333333000100\",\"paycheckBankDetainerIspb\":\"12345678\"," + + "\"portabilityApprovalDate\":\"2024-05-01T00:00:00.000Z\"}]," + + "\"paychecksBankLink\":[{\"employerName\":\"Acme\",\"employerDocument\":\"22222222000100\"," + + "\"paycheckBankCnpj\":\"44444444000100\",\"paycheckBankIspb\":\"87654321\"," + + "\"accountOpeningDate\":\"2020-02-01T00:00:00.000Z\"}]}," + + "\"qualifications\":{\"companyCnpj\":\"55555555000100\",\"occupationCode\":\"OUTRO\"," + + "\"occupationDescription\":\"Engineer\"," + + "\"informedIncome\":{\"frequency\":\"MENSAL\",\"amount\":10000.5," + + "\"date\":\"2026-01-01T00:00:00.000Z\"}," + + "\"informedPatrimony\":{\"amount\":500000,\"year\":2025," + + "\"date\":\"2025-12-31T00:00:00.000Z\"}," + + "\"economicActivities\":[{\"code\":\"0111301\",\"isMain\":true}," + + "{\"code\":\"4711302\",\"isMain\":false}]," + + "\"informedRevenue\":{\"amount\":1200000,\"frequency\":\"OTHER\"," + + "\"frequencyAdditionalInfo\":\"Seasonal\",\"year\":2025}}}"; + + IdentityResponse identity = client.service().getIdentityById("id-1").execute().body(); + + assertNotNull(identity); + assertEquals("EST-1", identity.getEstablishmentCode()); + assertEquals("Store", identity.getEstablishmentName()); + assertEquals("Mother", identity.getRelations().get(0).getType()); + assertEquals("Maria Silva", identity.getRelations().get(0).getName()); + + IdentityFinancialRelationships fr = identity.getFinancialRelationships(); + assertNotNull(fr.getStartDate()); + assertEquals(2, fr.getProductsServicesType().size()); + assertEquals("Other product", fr.getProductsServicesTypeAdditionalInfo()); + IdentityProcurator procurator = fr.getProcurators().get(0); + assertEquals("PROCURADOR", procurator.getType()); + assertEquals("11111111111", procurator.getDocumentNumber()); + assertEquals("CPF", procurator.getDocumentType()); + assertEquals("Joao", procurator.getCivilName()); + assertEquals("Jo", procurator.getSocialName()); + assertEquals("001", fr.getAccounts().get(0).getCompeCode()); + assertEquals("0", fr.getAccounts().get(0).getCheckDigit()); + assertEquals("INDIVIDUAL", fr.getAccounts().get(0).getSubtype()); + assertEquals("12345678", fr.getPortabilitiesReceived().get(0).getPaycheckBankDetainerIspb()); + assertNotNull(fr.getPortabilitiesReceived().get(0).getPortabilityApprovalDate()); + assertEquals("87654321", fr.getPaychecksBankLink().get(0).getPaycheckBankIspb()); + assertNotNull(fr.getPaychecksBankLink().get(0).getAccountOpeningDate()); + + IdentityQualifications q = identity.getQualifications(); + assertEquals("55555555000100", q.getCompanyCnpj()); + assertEquals("OUTRO", q.getOccupationCode()); + assertEquals("Engineer", q.getOccupationDescription()); + assertEquals("MENSAL", q.getInformedIncome().getFrequency()); + assertEquals(10000.5, q.getInformedIncome().getAmount()); + assertNotNull(q.getInformedIncome().getDate()); + assertEquals(2025, q.getInformedPatrimony().getYear()); + assertEquals(500000.0, q.getInformedPatrimony().getAmount()); + assertEquals(2, q.getEconomicActivities().size()); + assertTrue(q.getEconomicActivities().get(0).getIsMain()); + assertFalse(q.getEconomicActivities().get(1).getIsMain()); + assertEquals("0111301", q.getEconomicActivities().get(0).getCode()); + assertEquals("OTHER", q.getInformedRevenue().getFrequency()); + assertEquals("Seasonal", q.getInformedRevenue().getFrequencyAdditionalInfo()); + assertEquals(2025, q.getInformedRevenue().getYear()); + } + + @Test + void bill_parsesPayments() throws IOException { + responseJson = "{\"id\":\"bill-1\",\"payments\":[{\"id\":\"pay-1\",\"valueType\":\"FULL_PAYMENT\"," + + "\"paymentDate\":\"2026-09-10T00:00:00.000Z\",\"paymentMode\":\"PIX\",\"amount\":1500.75," + + "\"currencyCode\":\"BRL\"},{\"id\":\"pay-2\",\"valueType\":\"OTHER_PAYMENT\"," + + "\"paymentDate\":\"2026-09-11T00:00:00.000Z\",\"paymentMode\":null,\"amount\":10," + + "\"currencyCode\":\"BRL\"}]}"; + + Bill bill = client.service().getBill("bill-1").execute().body(); + + assertNotNull(bill); + assertEquals(2, bill.getPayments().size()); + BillPayment payment = bill.getPayments().get(0); + assertEquals("pay-1", payment.getId()); + assertEquals("FULL_PAYMENT", payment.getValueType()); + assertNotNull(payment.getPaymentDate()); + assertEquals("PIX", payment.getPaymentMode()); + assertEquals(1500.75, payment.getAmount()); + assertEquals("BRL", payment.getCurrencyCode()); + assertNull(bill.getPayments().get(1).getPaymentMode()); + } +}