Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>ai.pluggy</groupId>
<artifactId>pluggy-java</artifactId>
<version>1.13.0</version>
<version>1.14.0</version>

<packaging>jar</packaging>

Expand Down
12 changes: 12 additions & 0 deletions src/main/java/ai/pluggy/client/PluggyApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,6 +56,17 @@ Call<ItemResponse> updateItemSendMfa(@Path("id") String itemId,
@DELETE("/items/{id}")
Call<DeleteItemResponse> 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<ItemResourcesResponse> getItemResources(@Path("id") String itemId);

@GET("/items/{id}/resources")
Call<ItemResourcesResponse> getItemResources(@Path("id") String itemId,
@QueryMap ItemResourcesSearchRequest itemResourcesSearchRequest);

@GET("/accounts")
Call<AccountsResponse> getAccounts(@Query("itemId") String itemId);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> {

/**
* @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");
}
}
29 changes: 29 additions & 0 deletions src/main/java/ai/pluggy/client/response/ItemResource.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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;
}
32 changes: 32 additions & 0 deletions src/main/java/ai/pluggy/client/response/ItemResourceStatus.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>{@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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package ai.pluggy.client.response;

public class ItemResourcesResponse extends PageResponse<ItemResource> {
}
15 changes: 15 additions & 0 deletions src/main/java/ai/pluggy/client/response/ItemResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<ItemResourcesResponse> 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<ItemResourcesResponse> response = client.service()
.getItemResources(NON_EXISTING_ITEM_ID)
.execute();
ErrorResponse errorResponse = client.parseError(response);

assertNotNull(errorResponse);
assertEquals(404, errorResponse.getCode());
}
}
146 changes: 146 additions & 0 deletions src/test/java/ai/pluggy/client/unit/ItemResourcesTest.java
Original file line number Diff line number Diff line change
@@ -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<ItemResourcesResponse> 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<ItemResourcesResponse> 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());
}
}
Loading