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 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..17f4fae 100644 --- a/src/main/java/ai/pluggy/client/response/ItemResponse.java +++ b/src/main/java/ai/pluggy/client/response/ItemResponse.java @@ -23,4 +23,19 @@ 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; + + /** + * 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/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..745c714 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java @@ -0,0 +1,146 @@ +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.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_bothNull() throws IOException { + responseJson = "{\"id\":\"" + ITEM_ID + "\"," + + "\"resourcesCollectedAt\":null," + + "\"hasResourcesPendingAuthorization\":null}"; + + ItemResponse item = client.service().getItem(ITEM_ID).execute().body(); + + assertNotNull(item); + assertNull(item.getResourcesCollectedAt()); + assertNull(item.getHasResourcesPendingAuthorization()); + } +}