diff --git a/CLAUDE.md b/CLAUDE.md index 4c1a917..2655874 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` — 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 @@ -102,8 +104,8 @@ maven-publish.yml (declares `on: release created`, but in practice must be ### How to cut a release -1. Bump `` 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 `` 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` 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`. 5. Verify: `gh release view v`, the `maven-publish.yml` deploy job is green, and the version shows in `gh api /orgs/pluggyai/packages/maven/ai.pluggy.pluggy-java/versions`. diff --git a/README.md b/README.md index c34026f..246c1c1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pom.xml b/pom.xml index aaec26f..88d0ba9 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ ai.pluggy pluggy-java - 1.15.0 + 1.16.0 jar diff --git a/src/main/java/ai/pluggy/client/PluggyApiService.java b/src/main/java/ai/pluggy/client/PluggyApiService.java index 3357fac..fed1e9c 100644 --- a/src/main/java/ai/pluggy/client/PluggyApiService.java +++ b/src/main/java/ai/pluggy/client/PluggyApiService.java @@ -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; @@ -201,4 +205,77 @@ Call getInvestmentTransactions(@Path("id") Strin @POST("/connecttokens") Call 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)}. + * + *

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 createSmartTransferPreauthorization( + @Body CreateSmartTransferPreauthorizationRequest createSmartTransferPreauthorizationRequest); + + /** First page of the Smart Transfer preauthorizations. */ + @GET("/smart-transfers/preauthorizations") + Call getSmartTransferPreauthorizations(); + + @GET("/smart-transfers/preauthorizations") + Call 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 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()}). + * + *

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 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 cancelSmartTransferPreauthorizationDataConsent( + @Path("id") String preauthorizationId); + + /** First page of a Smart Transfer preauthorization's payments, newest first. */ + @GET("/smart-transfers/preauthorizations/{id}/payments") + Call getSmartTransferPreauthorizationPayments( + @Path("id") String preauthorizationId); + + @GET("/smart-transfers/preauthorizations/{id}/payments") + Call getSmartTransferPreauthorizationPayments( + @Path("id") String preauthorizationId, + @QueryMap SmartTransferPreauthorizationPaymentsSearchRequest smartTransferPreauthorizationPaymentsSearchRequest); + + /** Create a payment under a {@code COMPLETED} Smart Transfer preauthorization. */ + @POST("/smart-transfers/payments") + Call createSmartTransferPayment( + @Body CreateSmartTransferPaymentRequest createSmartTransferPaymentRequest); + + @GET("/smart-transfers/payments/{id}") + Call getSmartTransferPayment(@Path("id") String paymentId); } diff --git a/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java b/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java index 03a483c..0215504 100644 --- a/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java +++ b/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java @@ -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); } diff --git a/src/main/java/ai/pluggy/client/request/CreateSmartTransferPaymentRequest.java b/src/main/java/ai/pluggy/client/request/CreateSmartTransferPaymentRequest.java new file mode 100644 index 0000000..6b09175 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/CreateSmartTransferPaymentRequest.java @@ -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; + } +} diff --git a/src/main/java/ai/pluggy/client/request/CreateSmartTransferPreauthorizationRequest.java b/src/main/java/ai/pluggy/client/request/CreateSmartTransferPreauthorizationRequest.java new file mode 100644 index 0000000..2a199e7 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/CreateSmartTransferPreauthorizationRequest.java @@ -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 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 recipientIds) { + this.connectorId = connectorId; + this.parameters = parameters; + this.recipientIds = recipientIds; + this.callbackUrls = null; + this.clientPreauthorizationId = null; + this.configuration = null; + this.linkedJourney = null; + } +} diff --git a/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationParameter.java b/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationParameter.java new file mode 100644 index 0000000..0b42816 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationParameter.java @@ -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; + } +} diff --git a/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationPaymentsSearchRequest.java b/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationPaymentsSearchRequest.java new file mode 100644 index 0000000..11b88b4 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationPaymentsSearchRequest.java @@ -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 { + + 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"); + } +} diff --git a/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationsSearchRequest.java b/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationsSearchRequest.java new file mode 100644 index 0000000..3d669c0 --- /dev/null +++ b/src/main/java/ai/pluggy/client/request/SmartTransferPreauthorizationsSearchRequest.java @@ -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 { + + /** + * @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"); + } +} diff --git a/src/main/java/ai/pluggy/client/response/PaymentInstitution.java b/src/main/java/ai/pluggy/client/response/PaymentInstitution.java new file mode 100644 index 0000000..81282e1 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/PaymentInstitution.java @@ -0,0 +1,24 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Institution that holds a payment recipient's bank account. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentInstitution { + + String id; + String name; + String tradeName; + String ispb; + String compe; + Date createdAt; + Date updatedAt; +} diff --git a/src/main/java/ai/pluggy/client/response/PaymentRecipient.java b/src/main/java/ai/pluggy/client/response/PaymentRecipient.java new file mode 100644 index 0000000..f2c3d0b --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/PaymentRecipient.java @@ -0,0 +1,41 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Bank-account payment recipient. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentRecipient { + + /** Recipient discriminator, always {@code BANK_ACCOUNT}. */ + String type; + + String id; + + /** Account owner tax number, CPF or CNPJ (only numbers). */ + String taxNumber; + + /** Account owner name. */ + String name; + + PaymentInstitution paymentInstitution; + + /** Whether the recipient is the default one. */ + Boolean isDefault; + + PaymentRecipientAccount account; + + /** Pix key associated with the recipient, or null. */ + String pixKey; + + Date createdAt; + + Date updatedAt; +} diff --git a/src/main/java/ai/pluggy/client/response/PaymentRecipientAccount.java b/src/main/java/ai/pluggy/client/response/PaymentRecipientAccount.java new file mode 100644 index 0000000..33df170 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/PaymentRecipientAccount.java @@ -0,0 +1,25 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Bank account of a payment recipient. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentRecipientAccount { + + /** Branch (agency). */ + String branch; + + String number; + + /** + * Account type, documented as {@code CHECKING_ACCOUNT}, {@code SAVINGS_ACCOUNT} or + * {@code GUARANTEED_ACCOUNT}. + */ + String type; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferCallbackUrls.java b/src/main/java/ai/pluggy/client/response/SmartTransferCallbackUrls.java new file mode 100644 index 0000000..8836291 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferCallbackUrls.java @@ -0,0 +1,23 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Where to redirect the user after the Smart Transfer preauthorization flow completes or ends in + * error. Used both when creating a preauthorization and in the preauthorization response. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferCallbackUrls { + + /** Url to redirect to after the preauthorization was completed. */ + String success; + + /** Url to redirect to after the preauthorization ended in error. */ + String error; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferDataConsent.java b/src/main/java/ai/pluggy/client/response/SmartTransferDataConsent.java new file mode 100644 index 0000000..9008471 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferDataConsent.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; + +/** + * Permission to read the source account balance, requested together with a Smart Transfer + * preauthorization created with {@code linkedJourney}. Its status is refreshed from the + * institution when the preauthorization is retrieved by id; the list returns the last known + * status. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferDataConsent { + + SmartTransferDataConsentStatus status; + + /** + * Why the permission is {@code REJECTED}, for example {@code CUSTOMER_MANUALLY_REJECTED}, + * {@code CUSTOMER_MANUALLY_REVOKED}, {@code CONSENT_EXPIRED} or + * {@code CONSENT_MAX_DATE_REACHED}; null otherwise. + * + *

A String rather than an enum: new values may appear, and an enum would turn them into null + * instead of passing them through. + */ + String rejectionReason; + + /** When the status last changed. */ + Date updatedAt; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferDataConsentStatus.java b/src/main/java/ai/pluggy/client/response/SmartTransferDataConsentStatus.java new file mode 100644 index 0000000..0b9a412 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferDataConsentStatus.java @@ -0,0 +1,34 @@ +package ai.pluggy.client.response; + +import com.google.gson.annotations.SerializedName; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Status of the balance permission requested with a Smart Transfer preauthorization. + * + *

{@code AWAITING_AUTHORISATION} and {@code AUTHORISED} keep Open Finance's British spelling. A + * value added server-side after this SDK release deserializes as {@code null}. + */ +@AllArgsConstructor +public enum SmartTransferDataConsentStatus { + + /** The user has not approved the permission yet. */ + @SerializedName("AWAITING_AUTHORISATION") + AWAITING_AUTHORISATION("AWAITING_AUTHORISATION"), + + /** The source account balance can be read. */ + @SerializedName("AUTHORISED") + AUTHORISED("AUTHORISED"), + + /** + * The permission was rejected, revoked, cancelled or expired (see {@code rejectionReason}). It + * does not come back. + */ + @SerializedName("REJECTED") + REJECTED("REJECTED"); + + @Getter + private String value; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferErrorDetail.java b/src/main/java/ai/pluggy/client/response/SmartTransferErrorDetail.java new file mode 100644 index 0000000..2b085c1 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferErrorDetail.java @@ -0,0 +1,28 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Why a Smart Transfer preauthorization or payment ended in error. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferErrorDetail { + + /** + * Error code. For payments it is documented as {@code INFRASTRUCTURE_FAILURE}, + * {@code PAYMENT_DIFFERENT_FROM_CONSENT}, {@code UNKNOWN_ERROR}, {@code INVALID_PAYMENT_DETAIL}, + * {@code PAYMENT_REJECTED_BY_HOLDER} or {@code PAYMENT_REJECTED_BY_SPI}. + * + *

A String rather than an enum: other values can appear, and an enum would turn them into + * null instead of passing them through. + */ + String code; + + String description; + + String detail; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferOverdraft.java b/src/main/java/ai/pluggy/client/response/SmartTransferOverdraft.java new file mode 100644 index 0000000..445db2c --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferOverdraft.java @@ -0,0 +1,23 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Overdraft limit of a Smart Transfer source account, in BRL. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferOverdraft { + + /** Overdraft limit contracted, in BRL. */ + Double contracted; + + /** Part of the overdraft limit in use, in BRL. */ + Double used; + + /** Part of the overdraft limit still available, in BRL. */ + Double available; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPayment.java b/src/main/java/ai/pluggy/client/response/SmartTransferPayment.java new file mode 100644 index 0000000..febfac0 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPayment.java @@ -0,0 +1,38 @@ +package ai.pluggy.client.response; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Transfer made under a Smart Transfer preauthorization. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferPayment { + + String id; + + String preauthorizationId; + + SmartTransferPaymentStatus status; + + Double amount; + + String description; + + /** Recipient of the transfer; may be null until the payment is associated with one. */ + PaymentRecipient recipient; + + String clientPaymentId; + + Date createdAt; + + Date updatedAt; + + /** Set when the payment ended in error. */ + SmartTransferErrorDetail errorDetail; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPaymentStatus.java b/src/main/java/ai/pluggy/client/response/SmartTransferPaymentStatus.java new file mode 100644 index 0000000..1ceb120 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPaymentStatus.java @@ -0,0 +1,57 @@ +package ai.pluggy.client.response; + +import com.google.gson.annotations.SerializedName; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Lifecycle of a Smart Transfer payment. A value added server-side after this SDK release + * deserializes as {@code null}. + */ +@AllArgsConstructor +public enum SmartTransferPaymentStatus { + + /** Accepted under the preauthorization and ready to be processed. */ + @SerializedName("CONSENT_AUTHORIZED") + CONSENT_AUTHORIZED("CONSENT_AUTHORIZED"), + + /** The preauthorization was rejected at execution time. */ + @SerializedName("CONSENT_REJECTED") + CONSENT_REJECTED("CONSENT_REJECTED"), + + /** Submitted to the institution and awaiting confirmation. */ + @SerializedName("PAYMENT_PENDING") + PAYMENT_PENDING("PAYMENT_PENDING"), + + /** Accepted, but still needs an additional authorization. */ + @SerializedName("PAYMENT_PARTIALLY_ACCEPTED") + PAYMENT_PARTIALLY_ACCEPTED("PAYMENT_PARTIALLY_ACCEPTED"), + + /** The settlement is being processed. */ + @SerializedName("PAYMENT_SETTLEMENT_PROCESSING") + PAYMENT_SETTLEMENT_PROCESSING("PAYMENT_SETTLEMENT_PROCESSING"), + + /** The funds were debited from the payer account and are awaiting clearing. */ + @SerializedName("PAYMENT_SETTLEMENT_DEBTOR_ACCOUNT") + PAYMENT_SETTLEMENT_DEBTOR_ACCOUNT("PAYMENT_SETTLEMENT_DEBTOR_ACCOUNT"), + + /** Confirmed by the institution. */ + @SerializedName("PAYMENT_COMPLETED") + PAYMENT_COMPLETED("PAYMENT_COMPLETED"), + + /** Rejected after the consent was authorized. */ + @SerializedName("PAYMENT_REJECTED") + PAYMENT_REJECTED("PAYMENT_REJECTED"), + + /** An unexpected error occurred during the flow (see {@code errorDetail}). */ + @SerializedName("ERROR") + ERROR("ERROR"), + + /** The payment was canceled. */ + @SerializedName("CANCELED") + CANCELED("CANCELED"); + + @Getter + private String value; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPaymentsResponse.java b/src/main/java/ai/pluggy/client/response/SmartTransferPaymentsResponse.java new file mode 100644 index 0000000..a828984 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPaymentsResponse.java @@ -0,0 +1,4 @@ +package ai.pluggy.client.response; + +public class SmartTransferPaymentsResponse extends PageResponse { +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPeriodicLimit.java b/src/main/java/ai/pluggy/client/response/SmartTransferPeriodicLimit.java new file mode 100644 index 0000000..61b923e --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPeriodicLimit.java @@ -0,0 +1,23 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Transactional limit for one period of a Smart Transfer preauthorization. When sent, at least one + * of the two fields must be set. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferPeriodicLimit { + + /** Maximum number of transactions allowed in the period. */ + Integer quantityLimit; + + /** Maximum amount to be transacted in the period. */ + Double transactionLimit; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPeriodicLimits.java b/src/main/java/ai/pluggy/client/response/SmartTransferPeriodicLimits.java new file mode 100644 index 0000000..238b090 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPeriodicLimits.java @@ -0,0 +1,19 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Transactional limits per period of a Smart Transfer preauthorization. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferPeriodicLimits { + + SmartTransferPeriodicLimit day; + SmartTransferPeriodicLimit week; + SmartTransferPeriodicLimit month; + SmartTransferPeriodicLimit year; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorization.java b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorization.java new file mode 100644 index 0000000..ce512c9 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorization.java @@ -0,0 +1,50 @@ +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; + +/** + * Smart Transfer preauthorization: the payer's consent to send recurring transfers to a set of + * recipients without approving each one at the bank. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferPreauthorization { + + String id; + + SmartTransferPreauthorizationStatus status; + + /** Url where the payer gives the consent at the institution. */ + String consentUrl; + + String clientPreauthorizationId; + + SmartTransferCallbackUrls callbackUrls; + + List recipients; + + Connector connector; + + Date createdAt; + + Date updatedAt; + + SmartTransferPreauthorizationConfiguration configuration; + + /** Set when {@code status} is {@code ERROR}. */ + SmartTransferErrorDetail errorDetail; + + /** + * Permission to read the source account balance, requested with {@code linkedJourney}; null + * when it was not requested. + */ + SmartTransferDataConsent dataConsent; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationBalance.java b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationBalance.java new file mode 100644 index 0000000..764baf6 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationBalance.java @@ -0,0 +1,23 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * GET /smart-transfers/preauthorizations/{id}/balance response: the source account balance, read + * in real time from the institution. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferPreauthorizationBalance { + + /** Source account balance in BRL. */ + Double balance; + + /** Overdraft limit of the source account; null when the institution does not share it. */ + SmartTransferOverdraft overdraft; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationConfiguration.java b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationConfiguration.java new file mode 100644 index 0000000..7f48e93 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationConfiguration.java @@ -0,0 +1,25 @@ +package ai.pluggy.client.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Limits of a Smart Transfer preauthorization. Used both when creating a preauthorization and in + * the preauthorization response. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SmartTransferPreauthorizationConfiguration { + + /** Maximum amount reachable by the sum of all payments made under the consent. */ + Double totalAllowedAmount; + + /** Maximum amount for each payment made under the consent. */ + Double transactionLimit; + + SmartTransferPeriodicLimits periodicLimits; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationStatus.java b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationStatus.java new file mode 100644 index 0000000..6365579 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationStatus.java @@ -0,0 +1,37 @@ +package ai.pluggy.client.response; + +import com.google.gson.annotations.SerializedName; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Lifecycle of a Smart Transfer preauthorization. A value added server-side after this SDK release + * deserializes as {@code null}. + */ +@AllArgsConstructor +public enum SmartTransferPreauthorizationStatus { + + /** Created and awaiting the payer's consent (see {@code consentUrl}). */ + @SerializedName("CREATED") + CREATED("CREATED"), + + /** The payer authorized the consent; payments can now be created under it. */ + @SerializedName("COMPLETED") + COMPLETED("COMPLETED"), + + /** The consent was revoked after being authorized. */ + @SerializedName("REVOKED") + REVOKED("REVOKED"), + + /** The payer rejected the consent. */ + @SerializedName("REJECTED") + REJECTED("REJECTED"), + + /** The preauthorization flow failed unexpectedly (see {@code errorDetail}). */ + @SerializedName("ERROR") + ERROR("ERROR"); + + @Getter + private String value; +} diff --git a/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationsResponse.java b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationsResponse.java new file mode 100644 index 0000000..dbac5c2 --- /dev/null +++ b/src/main/java/ai/pluggy/client/response/SmartTransferPreauthorizationsResponse.java @@ -0,0 +1,5 @@ +package ai.pluggy.client.response; + +public class SmartTransferPreauthorizationsResponse + extends PageResponse { +} diff --git a/src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java b/src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java index 71bd345..4e2042d 100644 --- a/src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java +++ b/src/test/java/ai/pluggy/client/unit/BuilderNoArgsConstructorTest.java @@ -13,6 +13,9 @@ import ai.pluggy.client.response.IdentityResponse; import ai.pluggy.client.response.Investment; import ai.pluggy.client.response.ItemResponse; +import ai.pluggy.client.response.SmartTransferPayment; +import ai.pluggy.client.response.SmartTransferPreauthorization; +import ai.pluggy.client.response.SmartTransferPreauthorizationBalance; import ai.pluggy.client.response.TransactionsCursorResponse; import java.lang.reflect.Constructor; import java.lang.reflect.Modifier; @@ -29,7 +32,8 @@ 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); + SmartTransferPayment.class, SmartTransferPreauthorization.class, + SmartTransferPreauthorizationBalance.class, TransactionsCursorResponse.class); @Test void builderResponseClasses_havePublicNoArgsConstructor() throws Exception { diff --git a/src/test/java/ai/pluggy/client/unit/EncryptedParametersInterceptorTest.java b/src/test/java/ai/pluggy/client/unit/EncryptedParametersInterceptorTest.java new file mode 100644 index 0000000..82543f7 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/EncryptedParametersInterceptorTest.java @@ -0,0 +1,92 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ai.pluggy.client.auth.EncryptedParametersInterceptor; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * The interceptor RSA-encrypts item credentials only; other bodies that carry a {@code parameters} + * field must reach the API untouched. + */ +public class EncryptedParametersInterceptorTest { + + private static final MediaType JSON = MediaType.parse("application/json"); + + private OkHttpClient httpClient; + private String sentBody; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + String publicKey = "-----BEGIN PUBLIC KEY-----\n" + + Base64.getEncoder().encodeToString(generator.generateKeyPair().getPublic().getEncoded()) + + "\n-----END PUBLIC KEY-----"; + + httpClient = new OkHttpClient.Builder() + .addInterceptor(new EncryptedParametersInterceptor(publicKey)) + // capture what the encryption interceptor lets through, so no request leaves the process + .addInterceptor(chain -> { + Buffer buffer = new Buffer(); + chain.request().body().writeTo(buffer); + sentBody = buffer.readUtf8(); + return new okhttp3.Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(ResponseBody.create("{}", JSON)) + .build(); + }) + .build(); + } + + private JsonObject post(String path, String body) throws IOException { + Request request = new Request.Builder() + .url("https://api.pluggy.ai" + path) + .post(RequestBody.create(body, JSON)) + .build(); + httpClient.newCall(request).execute().close(); + return JsonParser.parseString(sentBody).getAsJsonObject(); + } + + @Test + void createItem_parametersAreEncrypted() throws IOException { + JsonObject sent = post("/items", "{\"connectorId\":0,\"parameters\":{\"user\":\"u\"}}"); + + assertTrue(sent.get("parameters").isJsonPrimitive()); + assertEquals(0, sent.get("connectorId").getAsInt()); + } + + @Test + void smartTransferPreauthorization_parametersAreSentAsIs() throws IOException { + JsonObject sent = post("/smart-transfers/preauthorizations", + "{\"connectorId\":612,\"parameters\":{\"cpf\":\"41679949500\"},\"recipientIds\":[]}"); + + assertTrue(sent.get("parameters").isJsonObject()); + assertEquals("41679949500", sent.getAsJsonObject("parameters").get("cpf").getAsString()); + } + + @Test + void itemRequestWithoutParameters_isSentAsIs() throws IOException { + JsonObject sent = post("/items/item-1/mfa", "{\"token\":\"123456\"}"); + + assertEquals("123456", sent.get("token").getAsString()); + } +} diff --git a/src/test/java/ai/pluggy/client/unit/SmartTransfersTest.java b/src/test/java/ai/pluggy/client/unit/SmartTransfersTest.java new file mode 100644 index 0000000..2bae0b9 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/SmartTransfersTest.java @@ -0,0 +1,601 @@ +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.CreateSmartTransferPaymentRequest; +import ai.pluggy.client.request.CreateSmartTransferPreauthorizationRequest; +import ai.pluggy.client.request.SmartTransferPreauthorizationParameter; +import ai.pluggy.client.request.SmartTransferPreauthorizationPaymentsSearchRequest; +import ai.pluggy.client.request.SmartTransferPreauthorizationsSearchRequest; +import ai.pluggy.client.response.ErrorResponse; +import ai.pluggy.client.response.PaymentRecipient; +import ai.pluggy.client.response.SmartTransferCallbackUrls; +import ai.pluggy.client.response.SmartTransferDataConsent; +import ai.pluggy.client.response.SmartTransferDataConsentStatus; +import ai.pluggy.client.response.SmartTransferPayment; +import ai.pluggy.client.response.SmartTransferPaymentStatus; +import ai.pluggy.client.response.SmartTransferPaymentsResponse; +import ai.pluggy.client.response.SmartTransferPeriodicLimit; +import ai.pluggy.client.response.SmartTransferPeriodicLimits; +import ai.pluggy.client.response.SmartTransferPreauthorization; +import ai.pluggy.client.response.SmartTransferPreauthorizationBalance; +import ai.pluggy.client.response.SmartTransferPreauthorizationConfiguration; +import ai.pluggy.client.response.SmartTransferPreauthorizationStatus; +import ai.pluggy.client.response.SmartTransferPreauthorizationsResponse; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import retrofit2.Response; + +public class SmartTransfersTest { + + private static final String PREAUTHORIZATION_ID = "db97ad98-5b8f-4027-a248-6e112a73ced8"; + private static final String PAYMENT_ID = "0f6b1b6e-2c4a-4e0b-9d55-6a1f0f1c3e21"; + private static final String RECIPIENT_ID = "7b110544-dfcf-406a-bab7-b14f14e2d0c7"; + + private static final String RECIPIENT_JSON = "{\"type\":\"BANK_ACCOUNT\"," + + "\"id\":\"" + RECIPIENT_ID + "\",\"name\":\"John Doe\",\"taxNumber\":\"11111111111\"," + + "\"isDefault\":false," + + "\"paymentInstitution\":{\"id\":\"abfd2a88-bc7b-407f-9fcc-395548ee6840\"," + + "\"name\":\"Banco XP S.A.\",\"tradeName\":\"BCO XP S.A.\",\"ispb\":\"33264668\"," + + "\"compe\":\"348\",\"createdAt\":\"2023-12-08T17:52:21.001Z\"," + + "\"updatedAt\":\"2023-12-08T17:52:21.001Z\"}," + + "\"account\":{\"type\":\"CHECKING_ACCOUNT\",\"number\":\"1111111\",\"branch\":\"0001\"}," + + "\"pixKey\":null,\"createdAt\":\"2024-07-31T15:55:47.886Z\"," + + "\"updatedAt\":\"2024-07-31T15:56:23.123Z\"}"; + + private PluggyClient client; + private Request lastRequest; + private String lastRequestBody; + 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(); + lastRequestBody = null; + if (lastRequest.body() != null) { + Buffer buffer = new Buffer(); + lastRequest.body().writeTo(buffer); + lastRequestBody = buffer.readUtf8(); + } + 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(); + } + + private static String preauthorizationJson(String dataConsentJson) { + return "{\"id\":\"" + PREAUTHORIZATION_ID + "\",\"status\":\"COMPLETED\"," + + "\"consentUrl\":\"https://consent.example.com\"," + + "\"clientPreauthorizationId\":\"client-preauth-1\"," + + "\"callbackUrls\":{\"success\":\"https://example.com/ok\"," + + "\"error\":\"https://example.com/error\"}," + + "\"recipients\":[" + RECIPIENT_JSON + "]," + + "\"connector\":{\"id\":612,\"name\":\"Nubank\",\"supportsSmartTransfers\":true}," + + "\"createdAt\":\"2026-09-01T12:00:00.000Z\",\"updatedAt\":\"2026-09-01T12:05:00.000Z\"," + + "\"configuration\":{\"totalAllowedAmount\":5000,\"transactionLimit\":500.5," + + "\"periodicLimits\":{\"day\":{\"quantityLimit\":3,\"transactionLimit\":1000}," + + "\"month\":{\"quantityLimit\":30}}}," + + "\"dataConsent\":" + dataConsentJson + "}"; + } + + private JsonObject lastRequestJson() { + return JsonParser.parseString(lastRequestBody).getAsJsonObject(); + } + + @Test + void createSmartTransferPreauthorization_withAllFields_sendsBodyAndParsesPreauthorization() + throws IOException { + responseJson = preauthorizationJson("{\"status\":\"AWAITING_AUTHORISATION\"," + + "\"rejectionReason\":null,\"updatedAt\":\"2026-09-01T12:00:00.000Z\"}"); + + CreateSmartTransferPreauthorizationRequest request = CreateSmartTransferPreauthorizationRequest + .builder() + .connectorId(612) + .parameters(new SmartTransferPreauthorizationParameter("41679949500", "41679495000100")) + .recipientIds(Collections.singletonList(RECIPIENT_ID)) + .callbackUrls(SmartTransferCallbackUrls.builder() + .success("https://example.com/ok") + .error("https://example.com/error") + .build()) + .clientPreauthorizationId("client-preauth-1") + .configuration(SmartTransferPreauthorizationConfiguration.builder() + .totalAllowedAmount(5000.0) + .transactionLimit(500.5) + .periodicLimits(SmartTransferPeriodicLimits.builder() + .day(SmartTransferPeriodicLimit.builder().quantityLimit(3).transactionLimit(1000.0).build()) + .build()) + .build()) + .linkedJourney(true) + .build(); + + Response response = client.service() + .createSmartTransferPreauthorization(request) + .execute(); + + assertEquals("POST", lastRequest.method()); + assertEquals("/smart-transfers/preauthorizations", lastRequest.url().encodedPath()); + + JsonObject body = lastRequestJson(); + assertEquals(612, body.get("connectorId").getAsInt()); + assertEquals("41679949500", body.getAsJsonObject("parameters").get("cpf").getAsString()); + assertEquals("41679495000100", body.getAsJsonObject("parameters").get("cnpj").getAsString()); + assertEquals(RECIPIENT_ID, body.getAsJsonArray("recipientIds").get(0).getAsString()); + assertEquals("https://example.com/ok", + body.getAsJsonObject("callbackUrls").get("success").getAsString()); + assertEquals("https://example.com/error", + body.getAsJsonObject("callbackUrls").get("error").getAsString()); + assertEquals("client-preauth-1", body.get("clientPreauthorizationId").getAsString()); + JsonObject configuration = body.getAsJsonObject("configuration"); + assertEquals(5000.0, configuration.get("totalAllowedAmount").getAsDouble()); + assertEquals(500.5, configuration.get("transactionLimit").getAsDouble()); + JsonObject day = configuration.getAsJsonObject("periodicLimits").getAsJsonObject("day"); + assertEquals(3, day.get("quantityLimit").getAsInt()); + assertEquals(1000.0, day.get("transactionLimit").getAsDouble()); + // limits that are not set are omitted, not sent as null + assertFalse(configuration.getAsJsonObject("periodicLimits").has("week")); + assertTrue(body.get("linkedJourney").getAsBoolean()); + + assertTrue(response.isSuccessful()); + SmartTransferPreauthorization preauthorization = response.body(); + assertNotNull(preauthorization); + assertEquals(PREAUTHORIZATION_ID, preauthorization.getId()); + assertEquals(SmartTransferPreauthorizationStatus.COMPLETED, preauthorization.getStatus()); + assertEquals("https://consent.example.com", preauthorization.getConsentUrl()); + assertEquals("client-preauth-1", preauthorization.getClientPreauthorizationId()); + assertEquals("https://example.com/ok", preauthorization.getCallbackUrls().getSuccess()); + assertEquals("https://example.com/error", preauthorization.getCallbackUrls().getError()); + assertEquals(612, preauthorization.getConnector().getId()); + assertTrue(preauthorization.getConnector().getSupportsSmartTransfers()); + assertEquals(Date.from(Instant.parse("2026-09-01T12:00:00Z")), preauthorization.getCreatedAt()); + assertEquals(Date.from(Instant.parse("2026-09-01T12:05:00Z")), preauthorization.getUpdatedAt()); + assertEquals(5000.0, preauthorization.getConfiguration().getTotalAllowedAmount()); + assertEquals(500.5, preauthorization.getConfiguration().getTransactionLimit()); + SmartTransferPeriodicLimits limits = preauthorization.getConfiguration().getPeriodicLimits(); + assertEquals(3, limits.getDay().getQuantityLimit()); + assertEquals(1000.0, limits.getDay().getTransactionLimit()); + assertEquals(30, limits.getMonth().getQuantityLimit()); + assertNull(limits.getMonth().getTransactionLimit()); + assertNull(limits.getWeek()); + assertNull(preauthorization.getErrorDetail()); + assertEquals(SmartTransferDataConsentStatus.AWAITING_AUTHORISATION, + preauthorization.getDataConsent().getStatus()); + + PaymentRecipient recipient = preauthorization.getRecipients().get(0); + assertEquals("BANK_ACCOUNT", recipient.getType()); + assertEquals(RECIPIENT_ID, recipient.getId()); + assertEquals("John Doe", recipient.getName()); + assertEquals("11111111111", recipient.getTaxNumber()); + assertFalse(recipient.getIsDefault()); + assertNull(recipient.getPixKey()); + assertEquals(Date.from(Instant.parse("2024-07-31T15:55:47.886Z")), recipient.getCreatedAt()); + assertEquals(Date.from(Instant.parse("2024-07-31T15:56:23.123Z")), recipient.getUpdatedAt()); + assertEquals("Banco XP S.A.", recipient.getPaymentInstitution().getName()); + assertEquals("BCO XP S.A.", recipient.getPaymentInstitution().getTradeName()); + assertEquals("33264668", recipient.getPaymentInstitution().getIspb()); + assertEquals("348", recipient.getPaymentInstitution().getCompe()); + assertNotNull(recipient.getPaymentInstitution().getCreatedAt()); + assertEquals("CHECKING_ACCOUNT", recipient.getAccount().getType()); + assertEquals("1111111", recipient.getAccount().getNumber()); + assertEquals("0001", recipient.getAccount().getBranch()); + } + + @Test + void createSmartTransferPreauthorization_requiredFieldsOnly_omitsLinkedJourneyAndOptionals() + throws IOException { + responseJson = preauthorizationJson("null"); + + CreateSmartTransferPreauthorizationRequest request = + new CreateSmartTransferPreauthorizationRequest(612, + new SmartTransferPreauthorizationParameter("41679949500"), + Collections.singletonList(RECIPIENT_ID)); + + client.service().createSmartTransferPreauthorization(request).execute(); + + JsonObject body = lastRequestJson(); + assertEquals(612, body.get("connectorId").getAsInt()); + assertEquals("41679949500", body.getAsJsonObject("parameters").get("cpf").getAsString()); + assertFalse(body.getAsJsonObject("parameters").has("cnpj")); + assertEquals(1, body.getAsJsonArray("recipientIds").size()); + assertFalse(body.has("linkedJourney")); + assertFalse(body.has("callbackUrls")); + assertFalse(body.has("clientPreauthorizationId")); + assertFalse(body.has("configuration")); + } + + @Test + void createSmartTransferPreauthorization_linkedJourneyFalse_isSent() throws IOException { + responseJson = preauthorizationJson("null"); + + client.service().createSmartTransferPreauthorization(CreateSmartTransferPreauthorizationRequest + .builder() + .connectorId(612) + .parameters(new SmartTransferPreauthorizationParameter("41679949500")) + .recipientIds(Collections.singletonList(RECIPIENT_ID)) + .linkedJourney(false) + .build()).execute(); + + JsonObject body = lastRequestJson(); + assertTrue(body.has("linkedJourney")); + assertFalse(body.get("linkedJourney").getAsBoolean()); + } + + @Test + void getSmartTransferPreauthorizations_withoutFilters_sendsNoQueryAndParsesPage() + throws IOException { + responseJson = "{\"page\":1,\"total\":1,\"totalPages\":1,\"results\":[" + + preauthorizationJson("null") + "]}"; + + Response response = client.service() + .getSmartTransferPreauthorizations() + .execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/smart-transfers/preauthorizations", lastRequest.url().encodedPath()); + assertNull(lastRequest.url().query()); + + SmartTransferPreauthorizationsResponse page = response.body(); + assertNotNull(page); + assertEquals(1, page.getPage()); + assertEquals(1, page.getTotal()); + assertEquals(1, page.getTotalPages()); + assertEquals(1, page.getResults().size()); + assertEquals(PREAUTHORIZATION_ID, page.getResults().get(0).getId()); + assertNull(page.getResults().get(0).getDataConsent()); + } + + @Test + void getSmartTransferPreauthorizations_withPaging_sendsQuery() throws IOException { + responseJson = "{\"page\":2,\"total\":60,\"totalPages\":2,\"results\":[]}"; + + SmartTransferPreauthorizationsResponse page = client.service() + .getSmartTransferPreauthorizations( + new SmartTransferPreauthorizationsSearchRequest().page(2).pageSize(50)) + .execute() + .body(); + + assertEquals("/smart-transfers/preauthorizations", lastRequest.url().encodedPath()); + assertEquals("2", lastRequest.url().queryParameter("page")); + assertEquals("50", lastRequest.url().queryParameter("pageSize")); + assertNotNull(page); + assertEquals(2, page.getPage()); + assertTrue(page.getResults().isEmpty()); + } + + @Test + void getSmartTransferPreauthorization_withDataConsent_parsesIt() throws IOException { + responseJson = preauthorizationJson("{\"status\":\"AUTHORISED\",\"rejectionReason\":null," + + "\"updatedAt\":\"2026-09-01T12:10:00.000Z\"}"); + + Response response = client.service() + .getSmartTransferPreauthorization(PREAUTHORIZATION_ID) + .execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/smart-transfers/preauthorizations/" + PREAUTHORIZATION_ID, + lastRequest.url().encodedPath()); + + SmartTransferDataConsent dataConsent = response.body().getDataConsent(); + assertNotNull(dataConsent); + assertEquals(SmartTransferDataConsentStatus.AUTHORISED, dataConsent.getStatus()); + assertNull(dataConsent.getRejectionReason()); + assertEquals(Date.from(Instant.parse("2026-09-01T12:10:00Z")), dataConsent.getUpdatedAt()); + } + + @Test + void getSmartTransferPreauthorization_rejectedDataConsent_parsesRejectionReason() + throws IOException { + responseJson = preauthorizationJson("{\"status\":\"REJECTED\"," + + "\"rejectionReason\":\"CUSTOMER_MANUALLY_REVOKED\"," + + "\"updatedAt\":\"2026-09-02T08:00:00.000Z\"}"); + + SmartTransferDataConsent dataConsent = client.service() + .getSmartTransferPreauthorization(PREAUTHORIZATION_ID) + .execute() + .body() + .getDataConsent(); + + assertEquals(SmartTransferDataConsentStatus.REJECTED, dataConsent.getStatus()); + assertEquals("CUSTOMER_MANUALLY_REVOKED", dataConsent.getRejectionReason()); + } + + @Test + void getSmartTransferPreauthorization_dataConsentNull_parsesAsNull() throws IOException { + responseJson = preauthorizationJson("null"); + + SmartTransferPreauthorization preauthorization = client.service() + .getSmartTransferPreauthorization(PREAUTHORIZATION_ID) + .execute() + .body(); + + assertNotNull(preauthorization); + assertEquals(PREAUTHORIZATION_ID, preauthorization.getId()); + assertNull(preauthorization.getDataConsent()); + } + + @Test + void getSmartTransferPreauthorization_errorStatus_parsesErrorDetail() throws IOException { + responseJson = "{\"id\":\"" + PREAUTHORIZATION_ID + "\",\"status\":\"ERROR\"," + + "\"recipients\":[],\"dataConsent\":null," + + "\"errorDetail\":{\"code\":\"SOME_CODE\",\"description\":\"Something failed\"," + + "\"detail\":\"More detail\"}}"; + + SmartTransferPreauthorization preauthorization = client.service() + .getSmartTransferPreauthorization(PREAUTHORIZATION_ID) + .execute() + .body(); + + assertEquals(SmartTransferPreauthorizationStatus.ERROR, preauthorization.getStatus()); + assertEquals("SOME_CODE", preauthorization.getErrorDetail().getCode()); + assertEquals("Something failed", preauthorization.getErrorDetail().getDescription()); + assertEquals("More detail", preauthorization.getErrorDetail().getDetail()); + } + + @Test + void getSmartTransferPreauthorizationBalance_withOverdraft_parsesIt() throws IOException { + responseJson = "{\"balance\":5.48," + + "\"overdraft\":{\"contracted\":1000,\"used\":250.5,\"available\":749.5}}"; + + Response response = client.service() + .getSmartTransferPreauthorizationBalance(PREAUTHORIZATION_ID) + .execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/smart-transfers/preauthorizations/" + PREAUTHORIZATION_ID + "/balance", + lastRequest.url().encodedPath()); + + SmartTransferPreauthorizationBalance balance = response.body(); + assertNotNull(balance); + assertEquals(5.48, balance.getBalance()); + assertNotNull(balance.getOverdraft()); + assertEquals(1000.0, balance.getOverdraft().getContracted()); + assertEquals(250.5, balance.getOverdraft().getUsed()); + assertEquals(749.5, balance.getOverdraft().getAvailable()); + } + + @Test + void getSmartTransferPreauthorizationBalance_overdraftNull_parsesAsNull() throws IOException { + responseJson = "{\"balance\":-12.3,\"overdraft\":null}"; + + SmartTransferPreauthorizationBalance balance = client.service() + .getSmartTransferPreauthorizationBalance(PREAUTHORIZATION_ID) + .execute() + .body(); + + assertNotNull(balance); + assertEquals(-12.3, balance.getBalance()); + assertNull(balance.getOverdraft()); + } + + @Test + void getSmartTransferPreauthorizationBalance_dataConsentNotAvailable_parsesErrorCode() + throws IOException { + responseCode = 403; + responseJson = "{\"code\":403," + + "\"codeDescription\":\"SMART_TRANSFER_DATA_CONSENT_NOT_AVAILABLE\"," + + "\"message\":\"The balance permission of this Smart Transfer Preauthorization is not " + + "available. It may be pending, rejected or revoked.\"}"; + + Response response = client.service() + .getSmartTransferPreauthorizationBalance(PREAUTHORIZATION_ID) + .execute(); + + assertFalse(response.isSuccessful()); + ErrorResponse error = client.parseError(response); + assertEquals(403, error.getCode()); + assertEquals("SMART_TRANSFER_DATA_CONSENT_NOT_AVAILABLE", error.getCodeDescription()); + } + + @Test + void cancelSmartTransferPreauthorizationDataConsent_sendsDeleteAndParsesPreauthorization() + throws IOException { + responseJson = preauthorizationJson("{\"status\":\"REJECTED\"," + + "\"rejectionReason\":\"CUSTOMER_MANUALLY_REVOKED\"," + + "\"updatedAt\":\"2026-09-03T09:00:00.000Z\"}"); + + Response response = client.service() + .cancelSmartTransferPreauthorizationDataConsent(PREAUTHORIZATION_ID) + .execute(); + + assertEquals("DELETE", lastRequest.method()); + assertEquals("/smart-transfers/preauthorizations/" + PREAUTHORIZATION_ID + "/data-consent", + lastRequest.url().encodedPath()); + assertNull(lastRequestBody); + + SmartTransferPreauthorization preauthorization = response.body(); + assertNotNull(preauthorization); + // only the balance permission is cancelled: the preauthorization stays active + assertEquals(SmartTransferPreauthorizationStatus.COMPLETED, preauthorization.getStatus()); + assertEquals(SmartTransferDataConsentStatus.REJECTED, + preauthorization.getDataConsent().getStatus()); + } + + @Test + void getSmartTransferPreauthorizationPayments_withoutFilters_sendsNoQueryAndParsesPage() + throws IOException { + responseJson = "{\"page\":1,\"total\":1,\"totalPages\":1,\"results\":[{" + + "\"id\":\"" + PAYMENT_ID + "\",\"preauthorizationId\":\"" + PREAUTHORIZATION_ID + "\"," + + "\"status\":\"PAYMENT_COMPLETED\",\"amount\":100.5,\"description\":\"Rent\"," + + "\"recipient\":" + RECIPIENT_JSON + ",\"clientPaymentId\":\"client-payment-1\"," + + "\"createdAt\":\"2026-09-05T10:00:00.000Z\",\"updatedAt\":\"2026-09-05T10:01:00.000Z\"}]}"; + + Response response = client.service() + .getSmartTransferPreauthorizationPayments(PREAUTHORIZATION_ID) + .execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/smart-transfers/preauthorizations/" + PREAUTHORIZATION_ID + "/payments", + lastRequest.url().encodedPath()); + assertNull(lastRequest.url().query()); + + SmartTransferPaymentsResponse page = response.body(); + assertNotNull(page); + assertEquals(1, page.getTotal()); + SmartTransferPayment payment = page.getResults().get(0); + assertEquals(PAYMENT_ID, payment.getId()); + assertEquals(SmartTransferPaymentStatus.PAYMENT_COMPLETED, payment.getStatus()); + assertEquals(100.5, payment.getAmount()); + assertEquals(RECIPIENT_ID, payment.getRecipient().getId()); + } + + @Test + void getSmartTransferPreauthorizationPayments_withFilters_sendsQuery() throws IOException { + responseJson = "{\"page\":1,\"total\":0,\"totalPages\":0,\"results\":[]}"; + + SmartTransferPreauthorizationPaymentsSearchRequest request = + new SmartTransferPreauthorizationPaymentsSearchRequest() + .from("2026-01-01") + .to("2026-12-31") + .page(1) + .pageSize(20); + client.service().getSmartTransferPreauthorizationPayments(PREAUTHORIZATION_ID, request) + .execute(); + + assertEquals("/smart-transfers/preauthorizations/" + PREAUTHORIZATION_ID + "/payments", + lastRequest.url().encodedPath()); + assertEquals(4, lastRequest.url().querySize()); + assertEquals("2026-01-01", lastRequest.url().queryParameter("from")); + assertEquals("2026-12-31", lastRequest.url().queryParameter("to")); + assertEquals("1", lastRequest.url().queryParameter("page")); + assertEquals("20", lastRequest.url().queryParameter("pageSize")); + } + + @Test + void smartTransferPreauthorizationPaymentsSearchRequest_invalidDate_throws() { + SmartTransferPreauthorizationPaymentsSearchRequest request = + new SmartTransferPreauthorizationPaymentsSearchRequest(); + + assertThrows(IllegalArgumentException.class, () -> request.from("01/01/2026")); + assertThrows(IllegalArgumentException.class, () -> request.to("2026-13-01")); + } + + @Test + void createSmartTransferPayment_sendsBodyAndParsesPayment() throws IOException { + responseJson = "{\"id\":\"" + PAYMENT_ID + "\"," + + "\"preauthorizationId\":\"" + PREAUTHORIZATION_ID + "\"," + + "\"status\":\"CONSENT_AUTHORIZED\",\"amount\":100.5,\"description\":\"Rent\"," + + "\"recipient\":" + RECIPIENT_JSON + ",\"clientPaymentId\":\"client-payment-1\"," + + "\"createdAt\":\"2026-09-05T10:00:00.000Z\",\"updatedAt\":\"2026-09-05T10:00:00.000Z\"}"; + + CreateSmartTransferPaymentRequest request = CreateSmartTransferPaymentRequest.builder() + .preauthorizationId(PREAUTHORIZATION_ID) + .recipientId(RECIPIENT_ID) + .amount(100.5) + .description("Rent") + .clientPaymentId("client-payment-1") + .build(); + + Response response = client.service() + .createSmartTransferPayment(request) + .execute(); + + assertEquals("POST", lastRequest.method()); + assertEquals("/smart-transfers/payments", lastRequest.url().encodedPath()); + JsonObject body = lastRequestJson(); + assertEquals(PREAUTHORIZATION_ID, body.get("preauthorizationId").getAsString()); + assertEquals(RECIPIENT_ID, body.get("recipientId").getAsString()); + assertEquals(100.5, body.get("amount").getAsDouble()); + assertEquals("Rent", body.get("description").getAsString()); + assertEquals("client-payment-1", body.get("clientPaymentId").getAsString()); + + SmartTransferPayment payment = response.body(); + assertNotNull(payment); + assertEquals(PAYMENT_ID, payment.getId()); + assertEquals(PREAUTHORIZATION_ID, payment.getPreauthorizationId()); + assertEquals(SmartTransferPaymentStatus.CONSENT_AUTHORIZED, payment.getStatus()); + assertEquals(100.5, payment.getAmount()); + assertEquals("Rent", payment.getDescription()); + assertEquals("client-payment-1", payment.getClientPaymentId()); + assertEquals("John Doe", payment.getRecipient().getName()); + assertEquals(Date.from(Instant.parse("2026-09-05T10:00:00Z")), payment.getCreatedAt()); + assertEquals(Date.from(Instant.parse("2026-09-05T10:00:00Z")), payment.getUpdatedAt()); + assertNull(payment.getErrorDetail()); + } + + @Test + void createSmartTransferPayment_requiredFieldsOnly_omitsOptionals() throws IOException { + responseJson = "{\"id\":\"" + PAYMENT_ID + "\",\"status\":\"PAYMENT_PENDING\"}"; + + client.service().createSmartTransferPayment( + new CreateSmartTransferPaymentRequest(PREAUTHORIZATION_ID, RECIPIENT_ID, 10.0)).execute(); + + JsonObject body = lastRequestJson(); + assertEquals(new HashSet<>(Arrays.asList("preauthorizationId", "recipientId", "amount")), + body.keySet()); + assertEquals(10.0, body.get("amount").getAsDouble()); + } + + @Test + void getSmartTransferPayment_rejected_parsesErrorDetailAndNullRecipient() throws IOException { + responseJson = "{\"id\":\"" + PAYMENT_ID + "\"," + + "\"preauthorizationId\":\"" + PREAUTHORIZATION_ID + "\"," + + "\"status\":\"PAYMENT_REJECTED\",\"amount\":100.5,\"recipient\":null," + + "\"createdAt\":\"2026-09-05T10:00:00.000Z\",\"updatedAt\":\"2026-09-05T10:02:00.000Z\"," + + "\"errorDetail\":{\"code\":\"PAYMENT_REJECTED_BY_HOLDER\"," + + "\"description\":\"The payment was rejected by the account holder\"}}"; + + Response response = client.service() + .getSmartTransferPayment(PAYMENT_ID) + .execute(); + + assertEquals("GET", lastRequest.method()); + assertEquals("/smart-transfers/payments/" + PAYMENT_ID, lastRequest.url().encodedPath()); + + SmartTransferPayment payment = response.body(); + assertNotNull(payment); + assertEquals(SmartTransferPaymentStatus.PAYMENT_REJECTED, payment.getStatus()); + assertNull(payment.getRecipient()); + assertNull(payment.getDescription()); + assertEquals("PAYMENT_REJECTED_BY_HOLDER", payment.getErrorDetail().getCode()); + assertEquals("The payment was rejected by the account holder", + payment.getErrorDetail().getDescription()); + assertNull(payment.getErrorDetail().getDetail()); + } + + @Test + void unknownStatuses_deserializeAsNull() throws IOException { + responseJson = "{\"id\":\"" + PREAUTHORIZATION_ID + "\",\"status\":\"SOME_FUTURE_STATUS\"," + + "\"dataConsent\":{\"status\":\"SOME_FUTURE_STATUS\",\"rejectionReason\":null," + + "\"updatedAt\":\"2026-09-01T12:00:00.000Z\"}}"; + + SmartTransferPreauthorization preauthorization = client.service() + .getSmartTransferPreauthorization(PREAUTHORIZATION_ID) + .execute() + .body(); + + // a status added after this SDK release deserializes as null, like every SDK enum + assertNotNull(preauthorization); + assertNull(preauthorization.getStatus()); + assertNull(preauthorization.getDataConsent().getStatus()); + } +}