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
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ The SDK is a thin Retrofit interface wrapped in a builder-configured client. Thr

Single Retrofit interface annotated with `@GET/@POST/@PATCH/@DELETE`. To add a new endpoint, add a method here; request/response DTOs live in `client/request/` and `client/response/`. The full list of supported endpoints is whatever is declared in this interface — there is no other routing layer.

The only payments surface is Smart Transfers (`/smart-transfers/*`): `SmartTransfer*` DTOs plus the generic `PaymentRecipient` / `PaymentInstitution` / `PaymentRecipientAccount`, which other payment endpoints can reuse. Types that appear both in the create request and in the response (`SmartTransferCallbackUrls`, `SmartTransferPreauthorizationConfiguration`) live in `client/response/` and are referenced from the request.

**3. Two interceptors handle cross-cutting concerns**

- `ApiKeyAuthInterceptor` (`client/auth/`): transparently fetches the `x-api-key` JWT via `POST /auth` on first use, caches it in `TokenProvider`, decodes the JWT `exp` claim to detect expiry, and on a 401/403 with an expired key refreshes once and retries the original request. Also sets `User-Agent: PluggyJava/<version>` — note this string is hardcoded in two places (`PluggyClient.authenticate` and `ApiKeyAuthInterceptor.requestWithAuth`) and is not auto-derived from `pom.xml`.
- `EncryptedParametersInterceptor` (`client/auth/`): only attached when `rsaPublicKey(...)` is set on the builder. RSA/ECB/OAEPPadding-encrypts the `parameters` JSON field on POST/PATCH `/items` requests before they leave OkHttp.
- `EncryptedParametersInterceptor` (`client/auth/`): only attached when `rsaPublicKey(...)` is set on the builder. RSA/ECB/OAEPPadding-encrypts the `parameters` JSON field on POST/PATCH `/items` requests before they leave OkHttp. All three conditions are required: Smart Transfer preauthorizations also send a `parameters` field, which must go out as plain JSON, and item requests without one (MFA) must pass through.

### JSON / Gson conventions — important gotcha

Expand Down Expand Up @@ -102,8 +104,8 @@ maven-publish.yml (declares `on: release created`, but in practice must be

### How to cut a release

1. Bump `<version>` in `pom.xml` (semver: `feat:` commits since the last tag → minor, `fix:`/`chore:` only → patch).
2. Open a PR with the bump. PR title must be a conventional commit (enforced by `pr-title.yml`), e.g. `chore(release): bump version to 1.10.0`.
1. Bump `<version>` in `pom.xml` (semver: `feat:` commits since the last tag → minor, `fix:`/`chore:` only → patch). **The bump goes in the same PR as the change it ships** (as #111 and #112 did): a `feat:`/`fix:` PR without it merges and publishes nothing. A separate `chore(release)` PR is only for releasing changes that were merged without a bump.
2. Open the PR. Its title must be a conventional commit (enforced by `pr-title.yml`), e.g. `feat: add Smart Transfers` or `chore(release): bump version to 1.10.0`.
3. Merge to `master`. The merge triggers `release.yml`, which tags `v<version>` and cuts the GitHub Release.
4. **Publish manually** (this does NOT happen on its own — see gotcha): `gh workflow run maven-publish.yml -f tag_version=v<version>`.
5. Verify: `gh release view v<version>`, the `maven-publish.yml` deploy job is green, and the version shows in `gh api /orgs/pluggyai/packages/maven/ai.pluggy.pluggy-java/versions`.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,28 @@ while (true) {
request = new ItemsCursorSearchRequest().clientUserId("user-123").after(page.getNextCursor());
}
```

### Smart Transfers

A Smart Transfer preauthorization is the payer's consent, given once at their bank, to send transfers to a set of payment recipients. Create it, send the payer to its `consentUrl`, and once its status is `COMPLETED` create payments under it:

```java
CreateSmartTransferPreauthorizationRequest request = CreateSmartTransferPreauthorizationRequest.builder()
.connectorId(connectorId)
.parameters(new SmartTransferPreauthorizationParameter("12345678900"))
.recipientIds(Collections.singletonList(recipientId))
.linkedJourney(true) // optional: also ask for permission to read the source account balance
.build();
SmartTransferPreauthorization preauthorization = pluggyClient.service()
.createSmartTransferPreauthorization(request)
.execute()
.body();
// redirect the payer to preauthorization.getConsentUrl()

SmartTransferPayment payment = pluggyClient.service()
.createSmartTransferPayment(new CreateSmartTransferPaymentRequest(preauthorization.getId(), recipientId, 100.0))
.execute()
.body();
```

With `linkedJourney`, `getSmartTransferPreauthorizationBalance(id)` reads the source account balance (and its overdraft, when the institution shares it) once `dataConsent` is `AUTHORISED`. Each call reads it from the institution in real time and counts toward that account's monthly Open Finance quota. `cancelSmartTransferPreauthorizationDataConsent(id)` cancels only that permission; the preauthorization keeps working.
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.15.0</version>
<version>1.16.0</version>

<packaging>jar</packaging>

Expand Down
77 changes: 77 additions & 0 deletions src/main/java/ai/pluggy/client/PluggyApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
import ai.pluggy.client.request.ConnectorsSearchRequest;
import ai.pluggy.client.request.CreateConnectTokenRequest;
import ai.pluggy.client.request.CreateItemRequest;
import ai.pluggy.client.request.CreateSmartTransferPaymentRequest;
import ai.pluggy.client.request.CreateSmartTransferPreauthorizationRequest;
import ai.pluggy.client.request.InvestmentTransactionsSearchRequest;
import ai.pluggy.client.request.ItemResourcesSearchRequest;
import ai.pluggy.client.request.ItemsCursorSearchRequest;
import ai.pluggy.client.request.SmartTransferPreauthorizationPaymentsSearchRequest;
import ai.pluggy.client.request.SmartTransferPreauthorizationsSearchRequest;
import ai.pluggy.client.request.TransactionsCursorSearchRequest;
import ai.pluggy.client.request.TransactionsSearchRequest;
import ai.pluggy.client.request.UpdateItemMfaRequest;
Expand Down Expand Up @@ -201,4 +205,77 @@ Call<InvestmentTransactionsResponse> getInvestmentTransactions(@Path("id") Strin

@POST("/connecttokens")
Call<ConnectTokenResponse> createConnectToken(@Body CreateConnectTokenRequest createConnectTokenRequest);

/**
* Create a Smart Transfer preauthorization. Redirect the payer to the returned
* {@link SmartTransferPreauthorization#getConsentUrl()} to authorize it; once its status is
* {@code COMPLETED}, payments can be created under it with
* {@link #createSmartTransferPayment(CreateSmartTransferPaymentRequest)}.
*
* <p>Set {@code linkedJourney} to also ask the payer for permission to read the source account
* balance (see {@link #getSmartTransferPreauthorizationBalance(String)}).
*/
@POST("/smart-transfers/preauthorizations")
Call<SmartTransferPreauthorization> createSmartTransferPreauthorization(
@Body CreateSmartTransferPreauthorizationRequest createSmartTransferPreauthorizationRequest);

/** First page of the Smart Transfer preauthorizations. */
@GET("/smart-transfers/preauthorizations")
Call<SmartTransferPreauthorizationsResponse> getSmartTransferPreauthorizations();

@GET("/smart-transfers/preauthorizations")
Call<SmartTransferPreauthorizationsResponse> getSmartTransferPreauthorizations(
@QueryMap SmartTransferPreauthorizationsSearchRequest smartTransferPreauthorizationsSearchRequest);

/**
* Retrieve a Smart Transfer preauthorization. This also refreshes the status of its
* {@code dataConsent} from the institution; the list returns the last known status.
*/
@GET("/smart-transfers/preauthorizations/{id}")
Call<SmartTransferPreauthorization> getSmartTransferPreauthorization(
@Path("id") String preauthorizationId);

/**
* Read the source account balance of a Smart Transfer preauthorization in real time from the
* institution. Requires a preauthorization created with {@code linkedJourney} whose
* {@code dataConsent} is {@code AUTHORISED}; otherwise 400
* {@code SMART_TRANSFER_DATA_CONSENT_NOT_REQUESTED} or 403
* {@code SMART_TRANSFER_DATA_CONSENT_NOT_AVAILABLE} (see
* {@link ErrorResponse#getCodeDescription()}).
*
* <p>Every call counts toward the institution's monthly Open Finance quota for the account; 429
* {@code BALANCE_OPEN_FINANCE_RATE_LIMIT} when it is reached.
*/
@GET("/smart-transfers/preauthorizations/{id}/balance")
Call<SmartTransferPreauthorizationBalance> getSmartTransferPreauthorizationBalance(
@Path("id") String preauthorizationId);

/**
* Cancel only the balance permission of a Smart Transfer preauthorization. The preauthorization
* stays active and its payments keep working. Safe to retry: a permission that already ended
* answers with {@code dataConsent.status} {@code REJECTED}.
*
* @return the preauthorization, with the cancelled permission
*/
@DELETE("/smart-transfers/preauthorizations/{id}/data-consent")
Call<SmartTransferPreauthorization> cancelSmartTransferPreauthorizationDataConsent(
@Path("id") String preauthorizationId);

/** First page of a Smart Transfer preauthorization's payments, newest first. */
@GET("/smart-transfers/preauthorizations/{id}/payments")
Call<SmartTransferPaymentsResponse> getSmartTransferPreauthorizationPayments(
@Path("id") String preauthorizationId);

@GET("/smart-transfers/preauthorizations/{id}/payments")
Call<SmartTransferPaymentsResponse> getSmartTransferPreauthorizationPayments(
@Path("id") String preauthorizationId,
@QueryMap SmartTransferPreauthorizationPaymentsSearchRequest smartTransferPreauthorizationPaymentsSearchRequest);

/** Create a payment under a {@code COMPLETED} Smart Transfer preauthorization. */
@POST("/smart-transfers/payments")
Call<SmartTransferPayment> createSmartTransferPayment(
@Body CreateSmartTransferPaymentRequest createSmartTransferPaymentRequest);

@GET("/smart-transfers/payments/{id}")
Call<SmartTransferPayment> getSmartTransferPayment(@Path("id") String paymentId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ public Response intercept(@NotNull Chain chain) throws IOException {

JsonObject jsonBody = this.transformBodyToJsonObject(originalBody);

// only item credentials are encrypted: other bodies with a "parameters" field (e.g. Smart
// Transfer preauthorizations) are sent as-is, and item requests without one (e.g. MFA) too
if (!Arrays.asList(methods).contains(method)
|| !originalRequest.url().encodedPath().contains(path) && !jsonBody.has("parameters")) {
|| !originalRequest.url().encodedPath().contains(path) || !jsonBody.has("parameters")) {
return chain.proceed(originalRequest);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package ai.pluggy.client.request;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;

/** POST /smart-transfers/payments request body. */
@Value
@AllArgsConstructor
@Builder
public class CreateSmartTransferPaymentRequest {

/** Preauthorization the payment is made under. Required. */
String preauthorizationId;

/** One of the preauthorization's recipients. Required. */
String recipientId;

/** Payment amount. Required. */
Double amount;

String description;

String clientPaymentId;

public CreateSmartTransferPaymentRequest(String preauthorizationId, String recipientId,
Double amount) {
this.preauthorizationId = preauthorizationId;
this.recipientId = recipientId;
this.amount = amount;
this.description = null;
this.clientPaymentId = null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package ai.pluggy.client.request;

import ai.pluggy.client.response.SmartTransferCallbackUrls;
import ai.pluggy.client.response.SmartTransferPreauthorizationConfiguration;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;

/** POST /smart-transfers/preauthorizations request body. */
@Value
@AllArgsConstructor
@Builder
public class CreateSmartTransferPreauthorizationRequest {

/** Connector of the payer's institution. Required. */
Integer connectorId;

/** Payer identification. Required. */
SmartTransferPreauthorizationParameter parameters;

/** Ids of the payment recipients the preauthorization allows transfers to. Required. */
List<String> recipientIds;

SmartTransferCallbackUrls callbackUrls;

String clientPreauthorizationId;

SmartTransferPreauthorizationConfiguration configuration;

/**
* When true, the user is also asked, in the same approval at the bank, for a permission to read
* the source account balance. Check {@code dataConsent} on the preauthorization and read the
* balance once it is {@code AUTHORISED}. Omitted from the request when null (the API defaults it
* to false).
*/
Boolean linkedJourney;

public CreateSmartTransferPreauthorizationRequest(Integer connectorId,
SmartTransferPreauthorizationParameter parameters, List<String> recipientIds) {
this.connectorId = connectorId;
this.parameters = parameters;
this.recipientIds = recipientIds;
this.callbackUrls = null;
this.clientPreauthorizationId = null;
this.configuration = null;
this.linkedJourney = null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ai.pluggy.client.request;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;

/** Identifies the payer of a Smart Transfer preauthorization. */
@Value
@AllArgsConstructor
@Builder
public class SmartTransferPreauthorizationParameter {

/** CPF of the payer. Required. */
String cpf;

/** CNPJ of the payer, for business accounts. */
String cnpj;

public SmartTransferPreauthorizationParameter(String cpf) {
this.cpf = cpf;
this.cnpj = null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package ai.pluggy.client.request;

import static ai.pluggy.utils.Asserts.assertNotNull;
import static ai.pluggy.utils.Asserts.assertValidDateString;

import java.util.HashMap;

public class SmartTransferPreauthorizationPaymentsSearchRequest extends HashMap<String, Object> {

public static final String DATE_PARAM_FORMAT_ISO = "yyyy-MM-dd";

/**
* @param fromDate String - only payments created from this date, in 'YYYY-MM-DD' format
* @return this instance, useful to continue adding params
*/
public SmartTransferPreauthorizationPaymentsSearchRequest from(String fromDate) {
assertValidDateString(fromDate, DATE_PARAM_FORMAT_ISO, "from");
put("from", fromDate);
return this;
}

/**
* @param toDate String - only payments created until this date, in 'YYYY-MM-DD' format
* @return this instance, useful to continue adding params
*/
public SmartTransferPreauthorizationPaymentsSearchRequest to(String toDate) {
assertValidDateString(toDate, DATE_PARAM_FORMAT_ISO, "to");
put("to", toDate);
return this;
}

/**
* @param page Integer - page number to fetch, starting at page=1.
* @return this instance, useful to continue adding params
*/
public SmartTransferPreauthorizationPaymentsSearchRequest 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 SmartTransferPreauthorizationPaymentsSearchRequest pageSize(Integer pageSize) {
assertNotNull(pageSize, "pageSize");
put("pageSize", pageSize);
return this;
}

public String getFrom() {
if (!containsKey("from")) {
return null;
}
return (String) get("from");
}

public String getTo() {
if (!containsKey("to")) {
return null;
}
return (String) get("to");
}

public Integer getPage() {
if (!containsKey("page")) {
return null;
}
return (Integer) get("page");
}

public Integer getPageSize() {
if (!containsKey("pageSize")) {
return null;
}
return (Integer) get("pageSize");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package ai.pluggy.client.request;

import static ai.pluggy.utils.Asserts.assertNotNull;

import java.util.HashMap;

public class SmartTransferPreauthorizationsSearchRequest extends HashMap<String, Object> {

/**
* @param page Integer - page number to fetch, starting at page=1.
* @return this instance, useful to continue adding params
*/
public SmartTransferPreauthorizationsSearchRequest 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 SmartTransferPreauthorizationsSearchRequest pageSize(Integer pageSize) {
assertNotNull(pageSize, "pageSize");
put("pageSize", pageSize);
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");
}
}
Loading
Loading