From f9ad21adfe60c8a35f760ee3b2654669fedeb25c Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Mon, 28 Sep 2026 17:22:42 -0300 Subject: [PATCH 1/3] feat(item): add Open Finance resources endpoint and Item fields Mirrors what the API exposes about the resources a financial institution declares for an item's Open Finance consent. - GET /items/{id}/resources as getItemResources(itemId) and getItemResources(itemId, ItemResourcesSearchRequest), paginated like the other list endpoints (page, pageSize) plus an optional status filter. Items on connectors other than Open Finance return an empty page, not an error. - ItemResponse.resourcesCollectedAt: when the institution's resource list was last read, null if it never was. An empty resources page means the institution shared nothing only when this is set. - ItemResponse.hasResourcesPendingAuthorization: true when the institution declares at least one resource PENDING_AUTHORISATION, i.e. the user still has to approve it at their bank. Always false for other connectors. `status` is an enum (ItemResourceStatus) following the existing @SerializedName pattern, keeping Open Finance's British spelling of PENDING_AUTHORISATION verbatim; a status added after this release deserializes as null. `type` stays a String: values outside the documented list can appear, and an enum would turn them into null instead of passing them through. Co-Authored-By: Claude Opus 5.5 --- .../ai/pluggy/client/PluggyApiService.java | 12 ++ .../request/ItemResourcesSearchRequest.java | 60 +++++++ .../pluggy/client/response/ItemResource.java | 29 ++++ .../client/response/ItemResourceStatus.java | 32 ++++ .../response/ItemResourcesResponse.java | 4 + .../pluggy/client/response/ItemResponse.java | 14 ++ .../integration/GetItemResourcesTest.java | 45 ++++++ .../pluggy/client/unit/ItemResourcesTest.java | 147 ++++++++++++++++++ 8 files changed, 343 insertions(+) create mode 100644 src/main/java/ai/pluggy/client/request/ItemResourcesSearchRequest.java create mode 100644 src/main/java/ai/pluggy/client/response/ItemResource.java create mode 100644 src/main/java/ai/pluggy/client/response/ItemResourceStatus.java create mode 100644 src/main/java/ai/pluggy/client/response/ItemResourcesResponse.java create mode 100644 src/test/java/ai/pluggy/client/integration/GetItemResourcesTest.java create mode 100644 src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java diff --git a/src/main/java/ai/pluggy/client/PluggyApiService.java b/src/main/java/ai/pluggy/client/PluggyApiService.java index bfb56db..7db5435 100644 --- a/src/main/java/ai/pluggy/client/PluggyApiService.java +++ b/src/main/java/ai/pluggy/client/PluggyApiService.java @@ -5,6 +5,7 @@ import ai.pluggy.client.request.CreateConnectTokenRequest; import ai.pluggy.client.request.CreateItemRequest; import ai.pluggy.client.request.InvestmentTransactionsSearchRequest; +import ai.pluggy.client.request.ItemResourcesSearchRequest; import ai.pluggy.client.request.TransactionsSearchRequest; import ai.pluggy.client.request.UpdateItemMfaRequest; import ai.pluggy.client.request.UpdateItemRequest; @@ -55,6 +56,17 @@ Call updateItemSendMfa(@Path("id") String itemId, @DELETE("/items/{id}") Call deleteItem(@Path("id") String existingItemId); + /** + * Open Finance only: the resources the financial institution declared for the item's consent. + * Items on other connectors return an empty page. + */ + @GET("/items/{id}/resources") + Call getItemResources(@Path("id") String itemId); + + @GET("/items/{id}/resources") + Call getItemResources(@Path("id") String itemId, + @QueryMap ItemResourcesSearchRequest itemResourcesSearchRequest); + @GET("/accounts") Call getAccounts(@Query("itemId") String itemId); diff --git a/src/main/java/ai/pluggy/client/request/ItemResourcesSearchRequest.java b/src/main/java/ai/pluggy/client/request/ItemResourcesSearchRequest.java new file mode 100644 index 0000000..876d384 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/ItemResourcesSearchRequest.java @@ -0,0 +1,60 @@ +package ai.pluggy.client.request; + +import static ai.pluggy.utils.Asserts.assertNotNull; + +import ai.pluggy.client.response.ItemResourceStatus; +import java.util.HashMap; + +public class ItemResourcesSearchRequest extends HashMap { + + /** + * @param page Integer - page number to fetch, starting at page=1. + * @return this instance, useful to continue adding params + */ + public ItemResourcesSearchRequest page(Integer page) { + assertNotNull(page, "page"); + put("page", page); + return this; + } + + /** + * @param pageSize Integer - page size value, indicates max items to fetch per page. + * @return this instance, useful to continue adding params + */ + public ItemResourcesSearchRequest pageSize(Integer pageSize) { + assertNotNull(pageSize, "pageSize"); + put("pageSize", pageSize); + return this; + } + + /** + * @param status ItemResourceStatus - only resources the institution reports with this status. + * @return this instance, useful to continue adding params + */ + public ItemResourcesSearchRequest status(ItemResourceStatus status) { + assertNotNull(status, "status"); + put("status", status.getValue()); + return this; + } + + public Integer getPage() { + if (!containsKey("page")) { + return null; + } + return (Integer) get("page"); + } + + public Integer getPageSize() { + if (!containsKey("pageSize")) { + return null; + } + return (Integer) get("pageSize"); + } + + public String getStatus() { + if (!containsKey("status")) { + return null; + } + return (String) get("status"); + } +} diff --git a/src/main/java/ai/pluggy/client/response/ItemResource.java b/src/main/java/ai/pluggy/client/response/ItemResource.java new file mode 100644 index 0000000..3e60201 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ItemResource.java @@ -0,0 +1,29 @@ +package ai.pluggy.client.response; + +import lombok.Builder; +import lombok.Data; + +/** + * One resource the financial institution declared for an item's Open Finance consent, reported + * verbatim. + */ +@Data +@Builder +public class ItemResource { + + /** The institution's identifier for the resource. */ + String resourceId; + + /** + * Open Finance resource type, documented as {@code ACCOUNT}, {@code CREDIT_CARD_ACCOUNT}, + * {@code LOAN}, {@code FINANCING}, {@code UNARRANGED_ACCOUNT_OVERDRAFT}, + * {@code INVOICE_FINANCING}, {@code BANK_FIXED_INCOME}, {@code CREDIT_FIXED_INCOME}, + * {@code VARIABLE_INCOME}, {@code TREASURE_TITLE} or {@code FUND}. + * + *

A String rather than an enum: other values can appear, and an enum would turn them into + * null instead of passing them through. + */ + String type; + + ItemResourceStatus status; +} diff --git a/src/main/java/ai/pluggy/client/response/ItemResourceStatus.java b/src/main/java/ai/pluggy/client/response/ItemResourceStatus.java new file mode 100644 index 0000000..79bd463 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ItemResourceStatus.java @@ -0,0 +1,32 @@ +package ai.pluggy.client.response; + +import com.google.gson.annotations.SerializedName; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * What the financial institution reports about one resource of an item's Open Finance consent. + * + *

{@code PENDING_AUTHORISATION} keeps Open Finance's British spelling: the user still has to + * approve the resource at their bank. A value added server-side after this SDK release + * deserializes as {@code null}. + */ +@AllArgsConstructor +public enum ItemResourceStatus { + + @SerializedName("AVAILABLE") + AVAILABLE("AVAILABLE"), + + @SerializedName("UNAVAILABLE") + UNAVAILABLE("UNAVAILABLE"), + + @SerializedName("TEMPORARILY_UNAVAILABLE") + TEMPORARILY_UNAVAILABLE("TEMPORARILY_UNAVAILABLE"), + + @SerializedName("PENDING_AUTHORISATION") + PENDING_AUTHORISATION("PENDING_AUTHORISATION"); + + @Getter + private String value; +} diff --git a/src/main/java/ai/pluggy/client/response/ItemResourcesResponse.java b/src/main/java/ai/pluggy/client/response/ItemResourcesResponse.java new file mode 100644 index 0000000..2b9c539 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/ItemResourcesResponse.java @@ -0,0 +1,4 @@ +package ai.pluggy.client.response; + +public class ItemResourcesResponse extends PageResponse { +} diff --git a/src/main/java/ai/pluggy/client/response/ItemResponse.java b/src/main/java/ai/pluggy/client/response/ItemResponse.java index 70cdc51..71fc2f5 100644 --- a/src/main/java/ai/pluggy/client/response/ItemResponse.java +++ b/src/main/java/ai/pluggy/client/response/ItemResponse.java @@ -23,4 +23,18 @@ public class ItemResponse { ItemStatusDetail statusDetail; String clientUserId; Integer consecutiveFailedLoginAttempts; + + /** + * Open Finance only. When the financial institution's resource list was last read for this + * item, or null if it never was. An empty {@code GET /items/{id}/resources} page means the + * institution shared nothing only when this is set. + */ + Date resourcesCollectedAt; + + /** + * Whether the financial institution declares at least one of this item's resources + * {@code PENDING_AUTHORISATION}: the user still has to approve it at their bank. Always false + * for connectors other than Open Finance. + */ + Boolean hasResourcesPendingAuthorization; } diff --git a/src/test/java/ai/pluggy/client/integration/GetItemResourcesTest.java b/src/test/java/ai/pluggy/client/integration/GetItemResourcesTest.java new file mode 100644 index 0000000..7f080d0 --- /dev/null +++ b/src/test/java/ai/pluggy/client/integration/GetItemResourcesTest.java @@ -0,0 +1,45 @@ +package ai.pluggy.client.integration; + +import static ai.pluggy.client.integration.helper.ItemHelper.NON_EXISTING_ITEM_ID; +import static ai.pluggy.client.integration.helper.ItemHelper.createPluggyBankItem; +import static ai.pluggy.client.integration.util.AssertionsUtils.assertSuccessful; +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.ErrorResponse; +import ai.pluggy.client.response.ItemResourcesResponse; +import ai.pluggy.client.response.ItemResponse; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import retrofit2.Response; + +public class GetItemResourcesTest extends BaseApiIntegrationTest { + + @Test + void getItemResources_nonOpenFinanceItem_emptyPage() throws IOException { + ItemResponse item = createPluggyBankItem(client); + this.getItemsIdCreated().add(item.getId()); + + Response response = client.service() + .getItemResources(item.getId()) + .execute(); + + assertSuccessful(response, client); + ItemResourcesResponse page = response.body(); + assertNotNull(page); + assertNotNull(page.getResults()); + assertTrue(page.getResults().isEmpty()); + } + + @Test + void getItemResources_nonExistingItem_errorResponse404() throws IOException { + Response response = client.service() + .getItemResources(NON_EXISTING_ITEM_ID) + .execute(); + ErrorResponse errorResponse = client.parseError(response); + + assertNotNull(errorResponse); + assertEquals(404, errorResponse.getCode()); + } +} diff --git a/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java b/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java new file mode 100644 index 0000000..17b3175 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java @@ -0,0 +1,147 @@ +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.request.ItemResourcesSearchRequest; +import ai.pluggy.client.response.ItemResource; +import ai.pluggy.client.response.ItemResourceStatus; +import ai.pluggy.client.response.ItemResourcesResponse; +import ai.pluggy.client.response.ItemResponse; +import java.io.IOException; +import java.time.Instant; +import java.util.Date; +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 ItemResourcesTest { + + private static final String ITEM_ID = "d0e8448e-0156-4b4a-ae6c-3e2a6d9bff5c"; + + 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 getItemResources_withFilters_sendsQueryAndParsesPage() throws IOException { + responseJson = "{\"page\":2,\"total\":52,\"totalPages\":2,\"results\":[" + + "{\"resourceId\":\"6c2d8b41-7e5a-4f03-b19d-8a4e2c7f5b60\"," + + "\"type\":\"CREDIT_CARD_ACCOUNT\",\"status\":\"PENDING_AUTHORISATION\"}," + + "{\"resourceId\":\"a3e71d92-5b48-4c6f-8e20-1d9c3b7a4e50\"," + + "\"type\":\"FUND\",\"status\":\"PENDING_AUTHORISATION\"}]}"; + + ItemResourcesSearchRequest request = new ItemResourcesSearchRequest() + .page(2) + .pageSize(50) + .status(ItemResourceStatus.PENDING_AUTHORISATION); + Response response = client.service() + .getItemResources(ITEM_ID, request) + .execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/items/" + ITEM_ID + "/resources", lastRequest.url().encodedPath()); + assertEquals("2", lastRequest.url().queryParameter("page")); + assertEquals("50", lastRequest.url().queryParameter("pageSize")); + assertEquals("PENDING_AUTHORISATION", lastRequest.url().queryParameter("status")); + + assertTrue(response.isSuccessful()); + ItemResourcesResponse page = response.body(); + assertNotNull(page); + assertEquals(2, page.getPage()); + assertEquals(52, page.getTotal()); + assertEquals(2, page.getTotalPages()); + assertEquals(2, page.getResults().size()); + + ItemResource resource = page.getResults().get(0); + assertEquals("6c2d8b41-7e5a-4f03-b19d-8a4e2c7f5b60", resource.getResourceId()); + assertEquals("CREDIT_CARD_ACCOUNT", resource.getType()); + assertEquals(ItemResourceStatus.PENDING_AUTHORISATION, resource.getStatus()); + } + + @Test + void getItemResources_withoutFilters_sendsNoQueryAndParsesEmptyPage() throws IOException { + responseJson = "{\"page\":1,\"total\":0,\"totalPages\":0,\"results\":[]}"; + + Response response = client.service() + .getItemResources(ITEM_ID) + .execute(); + + assertEquals("/items/" + ITEM_ID + "/resources", lastRequest.url().encodedPath()); + assertNull(lastRequest.url().query()); + + ItemResourcesResponse page = response.body(); + assertNotNull(page); + assertEquals(0, page.getTotal()); + assertTrue(page.getResults().isEmpty()); + } + + @Test + void getItemResources_unknownTypeAndStatus_doNotFailToParse() throws IOException { + responseJson = "{\"page\":1,\"total\":1,\"totalPages\":1,\"results\":[" + + "{\"resourceId\":\"1f9a5e0c-3d2b-4a1e-9c7f-2b8d6e4a1c30\"," + + "\"type\":\"EXCHANGE\",\"status\":\"SOME_FUTURE_STATUS\"}]}"; + + ItemResourcesResponse page = client.service().getItemResources(ITEM_ID).execute().body(); + + assertNotNull(page); + ItemResource resource = page.getResults().get(0); + // an undocumented type is passed through as-is + assertEquals("EXCHANGE", resource.getType()); + // a status added after this SDK release deserializes as null, like every SDK enum + assertNull(resource.getStatus()); + } + + @Test + void getItem_resourcesFields_parsed() throws IOException { + responseJson = "{\"id\":\"" + ITEM_ID + "\"," + + "\"resourcesCollectedAt\":\"2026-09-01T12:30:00.000Z\"," + + "\"hasResourcesPendingAuthorization\":true}"; + + ItemResponse item = client.service().getItem(ITEM_ID).execute().body(); + + assertNotNull(item); + assertEquals(Date.from(Instant.parse("2026-09-01T12:30:00Z")), item.getResourcesCollectedAt()); + assertTrue(item.getHasResourcesPendingAuthorization()); + } + + @Test + void getItem_resourcesNeverCollected_nullDateAndFalse() throws IOException { + responseJson = "{\"id\":\"" + ITEM_ID + "\"," + + "\"resourcesCollectedAt\":null," + + "\"hasResourcesPendingAuthorization\":false}"; + + ItemResponse item = client.service().getItem(ITEM_ID).execute().body(); + + assertNotNull(item); + assertNull(item.getResourcesCollectedAt()); + assertFalse(item.getHasResourcesPendingAuthorization()); + } +} From e50f81c82cfc5be6f8a91c68a354aec2e9c1c7a1 Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Tue, 29 Sep 2026 14:35:01 -0300 Subject: [PATCH 2/3] feat: make hasResourcesPendingAuthorization nullable The API reports null while the institution's resource list has not been read yet, and for connectors other than Open Finance. Co-Authored-By: Claude Opus 5.5 --- src/main/java/ai/pluggy/client/response/ItemResponse.java | 7 ++++--- src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/ai/pluggy/client/response/ItemResponse.java b/src/main/java/ai/pluggy/client/response/ItemResponse.java index 71fc2f5..17f4fae 100644 --- a/src/main/java/ai/pluggy/client/response/ItemResponse.java +++ b/src/main/java/ai/pluggy/client/response/ItemResponse.java @@ -32,9 +32,10 @@ public class ItemResponse { Date resourcesCollectedAt; /** - * Whether the financial institution declares at least one of this item's resources - * {@code PENDING_AUTHORISATION}: the user still has to approve it at their bank. Always false - * for connectors other than Open Finance. + * Open Finance only. Whether the financial institution declares at least one of this item's + * resources {@code PENDING_AUTHORISATION}: the user still has to approve it at their bank. False + * when the resource list was read and none is; null for connectors other than Open Finance, and + * while the resource list has not been read yet ({@code resourcesCollectedAt} is null). */ Boolean hasResourcesPendingAuthorization; } diff --git a/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java b/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java index 17b3175..745c714 100644 --- a/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java +++ b/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java @@ -1,7 +1,6 @@ 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; @@ -133,15 +132,15 @@ void getItem_resourcesFields_parsed() throws IOException { } @Test - void getItem_resourcesNeverCollected_nullDateAndFalse() throws IOException { + void getItem_resourcesNeverCollected_bothNull() throws IOException { responseJson = "{\"id\":\"" + ITEM_ID + "\"," + "\"resourcesCollectedAt\":null," - + "\"hasResourcesPendingAuthorization\":false}"; + + "\"hasResourcesPendingAuthorization\":null}"; ItemResponse item = client.service().getItem(ITEM_ID).execute().body(); assertNotNull(item); assertNull(item.getResourcesCollectedAt()); - assertFalse(item.getHasResourcesPendingAuthorization()); + assertNull(item.getHasResourcesPendingAuthorization()); } } From 24a52450ef7225a90b2fe53e65c579f701b13d2f Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Tue, 29 Sep 2026 15:33:47 -0300 Subject: [PATCH 3/3] chore(release): bump version to 1.14.0 Minor: feat commits since v1.13.0 (health.incidents and the item resources endpoint and fields). Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8d35982..004fe82 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ ai.pluggy pluggy-java - 1.13.0 + 1.14.0 jar