diff --git a/CHANGELOG.md b/CHANGELOG.md index b2066b02f..55aadee9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [9.7.0] +### Added + - Implement Portfolio Trading Account Creation; + ## [9.6.0] ### Added - Implement for Document ingestion; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentClientConfig.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentClientConfig.java index d5c6289c8..09e913129 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentClientConfig.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentClientConfig.java @@ -12,6 +12,7 @@ import com.backbase.investment.api.service.v1.InvestmentProductsApi; import com.backbase.investment.api.service.v1.PaymentsApi; import com.backbase.investment.api.service.v1.PortfolioApi; +import com.backbase.investment.api.service.v1.PortfolioTradingAccountsApi; import com.backbase.stream.clients.config.CompositeApiClientConfig; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.ObjectMapper; @@ -117,6 +118,12 @@ public PaymentsApi paymentsApi(ApiClient investmentApiClient) { return new PaymentsApi(investmentApiClient); } + @Bean + @ConditionalOnMissingBean + public PortfolioTradingAccountsApi portfolioTradingAccountsApi(ApiClient investmentApiClient) { + return new PortfolioTradingAccountsApi(investmentApiClient); + } + @Bean @ConditionalOnMissingBean public CurrencyApi currencyApi(ApiClient investmentApiClient) { diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentServiceConfiguration.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentServiceConfiguration.java index a6fbe808c..dc3561f7d 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentServiceConfiguration.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentServiceConfiguration.java @@ -11,6 +11,7 @@ import com.backbase.investment.api.service.v1.InvestmentProductsApi; import com.backbase.investment.api.service.v1.PaymentsApi; import com.backbase.investment.api.service.v1.PortfolioApi; +import com.backbase.investment.api.service.v1.PortfolioTradingAccountsApi; import com.backbase.stream.clients.autoconfigure.DbsApiClientsAutoConfiguration; import com.backbase.stream.investment.saga.InvestmentAssetUniverseSaga; import com.backbase.stream.investment.saga.InvestmentContentSaga; @@ -59,10 +60,10 @@ public CustomIntegrationApiService customIntegrationApiService(ApiClient apiClie @Bean public InvestmentPortfolioService investmentPortfolioService(PortfolioApi portfolioApi, - InvestmentProductsApi investmentProductsApi, PaymentsApi paymentsApi, + InvestmentProductsApi investmentProductsApi, PaymentsApi paymentsApi, PortfolioTradingAccountsApi portfolioTradingAccountsApi, InvestmentIngestionConfigurationProperties configurationProperties) { return new InvestmentPortfolioService(investmentProductsApi, portfolioApi, paymentsApi, - configurationProperties); + portfolioTradingAccountsApi, configurationProperties); } @Bean diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentData.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentData.java index e6ab6ea3c..5f5f59c7c 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentData.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentData.java @@ -5,6 +5,7 @@ import com.backbase.investment.api.service.v1.model.PortfolioList; import com.backbase.investment.api.service.v1.model.PortfolioProduct; import com.backbase.investment.api.service.v1.model.ProductTypeEnum; +import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -28,6 +29,7 @@ public class InvestmentData { private List portfolioProducts; private InvestmentAssetData investmentAssetData; private List portfolios; + private List investmentPortfolioTradingAccounts; public Map> getClientsByLeExternalId() { Map> clientsByLeExternalId = new HashMap<>(); diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/InvestmentPortfolioTradingAccount.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/InvestmentPortfolioTradingAccount.java new file mode 100644 index 000000000..2833b0a3c --- /dev/null +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/InvestmentPortfolioTradingAccount.java @@ -0,0 +1,14 @@ +package com.backbase.stream.investment.model; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class InvestmentPortfolioTradingAccount { + private String portfolioExternalId; + private String accountId; + private String accountExternalId; + private Boolean isDefault; + private Boolean isInternal; +} diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java index 37d4ef3fc..11d9469c1 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java @@ -1,8 +1,10 @@ package com.backbase.stream.investment.saga; +import com.backbase.investment.api.service.v1.model.PortfolioList; import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties; import com.backbase.stream.investment.InvestmentData; import com.backbase.stream.investment.InvestmentTask; +import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; import com.backbase.stream.investment.service.AsyncTaskService; import com.backbase.stream.investment.service.InvestmentClientService; import com.backbase.stream.investment.service.InvestmentModelPortfolioService; @@ -56,6 +58,7 @@ public class InvestmentSaga implements StreamTaskExecutor { public static final String RESULT_FAILED = "failed"; private static final String INVESTMENT_PRODUCTS = "investment-products"; + private static final String INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS = "investment-portfolio-trading-accounts"; private static final String INVESTMENT_PORTFOLIO_MODELS = "investment-portfolio-models"; private static final String INVESTMENT_PORTFOLIOS = "investment-portfolios"; private static final String PROCESSING_PREFIX = "Processing "; @@ -81,6 +84,7 @@ public Mono executeTask(InvestmentTask streamTask) { .flatMap(this::upsertClients) .flatMap(this::upsertInvestmentProducts) .flatMap(this::upsertInvestmentPortfolios) + .flatMap(this::upsertPortfolioTradingAccounts) .flatMap(this::upsertInvestmentPortfolioDeposits) .flatMap(this::upsertPortfoliosAllocations) .doOnSuccess(completedTask -> log.info( @@ -134,6 +138,14 @@ private Mono upsertPortfoliosAllocations(InvestmentTask investme data.getPortfolioProducts(), investmentTask.getData().getInvestmentAssetData())) .collectList() + .doOnError(throwable -> { + log.error("Allocation generation failed for portfolios:{} taskId={}", + data.getPortfolios().stream().map(PortfolioList::getUuid).toList(), investmentTask.getId(), + throwable); + investmentTask.error(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_FAILED, + investmentTask.getName(), investmentTask.getId(), + "Failed to upsert investment portfolio trading accounts: " + throwable.getMessage()); + }) .map(o -> investmentTask) ); } @@ -263,6 +275,36 @@ private Mono upsertInvestmentProducts(InvestmentTask investmentT }); } + private Mono upsertPortfolioTradingAccounts(InvestmentTask investmentTask) { + List investmentPortfolioTradingAccounts = investmentTask.getData() + .getInvestmentPortfolioTradingAccounts(); + int accountsCount = investmentPortfolioTradingAccounts.size(); + + log.info("Starting investment portfolio trading accounts upsert: taskId={}, arrangementCount={}", + investmentTask.getId(), accountsCount); + + investmentTask.info(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, null, investmentTask.getName(), + investmentTask.getId(), PROCESSING_PREFIX + accountsCount + " investment portfolio trading accounts"); + + return investmentPortfolioService.upsertPortfolioTradingAccounts(investmentPortfolioTradingAccounts) + .map(products -> { + investmentTask.info(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_CREATED, + investmentTask.getName(), investmentTask.getId(), + UPSERTED_PREFIX + products.size() + " investment portfolio trading accounts"); + log.info("Successfully upserted all investment portfolio trading accounts: taskId={}, productCount={}", + investmentTask.getId(), products.size()); + + return investmentTask; + }) + .doOnError(throwable -> { + log.error("Failed to upsert investment portfolio trading accounts: taskId={}, arrangementCount={}", + investmentTask.getId(), accountsCount, throwable); + investmentTask.error(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_FAILED, + investmentTask.getName(), investmentTask.getId(), + "Failed to upsert investment portfolio trading accounts: " + throwable.getMessage()); + }); + } + /** * Upserts investment clients for all users in the investment data. * diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java index 989bcb76a..ea8288b22 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java @@ -104,7 +104,7 @@ public Mono getOrCreateAsset(com.backbase. // If asset exists, log and return it .flatMap(a -> { log.info("Asset already exists with Asset Identifier : {}", assetIdentifier); - return investmentRestAssetUniverseService.patchAsset(a, asset).thenReturn(a); + return investmentRestAssetUniverseService.patchAsset(a, asset, categoryIdByCode).thenReturn(a); }) .map(assetMapper::map) // If Mono is empty (asset not found), create the asset diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java index 209b1231d..c41af64d2 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java @@ -3,6 +3,7 @@ import com.backbase.investment.api.service.v1.InvestmentProductsApi; import com.backbase.investment.api.service.v1.PaymentsApi; import com.backbase.investment.api.service.v1.PortfolioApi; +import com.backbase.investment.api.service.v1.PortfolioTradingAccountsApi; import com.backbase.investment.api.service.v1.model.Deposit; import com.backbase.investment.api.service.v1.model.DepositRequest; import com.backbase.investment.api.service.v1.model.DepositTypeEnum; @@ -10,11 +11,14 @@ import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; import com.backbase.investment.api.service.v1.model.PaginatedDepositList; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioProductList; +import com.backbase.investment.api.service.v1.model.PaginatedPortfolioTradingAccountList; import com.backbase.investment.api.service.v1.model.PatchedPortfolioProductCreateUpdateRequest; import com.backbase.investment.api.service.v1.model.PatchedPortfolioUpdateRequest; import com.backbase.investment.api.service.v1.model.PortfolioList; import com.backbase.investment.api.service.v1.model.PortfolioProduct; import com.backbase.investment.api.service.v1.model.PortfolioProductCreateUpdateRequest; +import com.backbase.investment.api.service.v1.model.PortfolioTradingAccount; +import com.backbase.investment.api.service.v1.model.PortfolioTradingAccountRequest; import com.backbase.investment.api.service.v1.model.ProductTypeEnum; import com.backbase.investment.api.service.v1.model.Status08fEnum; import com.backbase.investment.api.service.v1.model.StatusA3dEnum; @@ -22,6 +26,7 @@ import com.backbase.stream.investment.InvestmentArrangement; import com.backbase.stream.investment.InvestmentData; import com.backbase.stream.investment.ModelPortfolio; +import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Collection; @@ -69,6 +74,7 @@ public class InvestmentPortfolioService { private final InvestmentProductsApi productsApi; private final PortfolioApi portfolioApi; private final PaymentsApi paymentsApi; + private final PortfolioTradingAccountsApi portfolioTradingAccountsApi; private final InvestmentIngestionConfigurationProperties config; public Mono> upsertPortfolios(List investmentArrangements, @@ -564,6 +570,294 @@ private Mono createDeposit(PortfolioList portfolio, double defaultAmoun }); } + /** + * Upserts portfolio trading accounts derived from the provided investment portfolio accounts. + * + *

This method constructs {@link PortfolioTradingAccount} instances directly from + * {@link InvestmentPortfolioTradingAccount} data, resolving each account's portfolio UUID + * via the portfolio service before upserting. + * + *

Processing flow: + *

    + *
  1. Validates input list is not null or empty
  2. + *
  3. For each investment portfolio account: + *
      + *
    • Resolves the portfolio UUID from {@code portfolioExternalId}
    • + *
    • Constructs a {@link PortfolioTradingAccount} with all available fields
    • + *
    • Creates or updates the trading account via the API
    • + *
    + *
  4. + *
  5. Failed accounts are logged and skipped to prevent batch failures
  6. + *
+ * + * @param investmentPortfolioTradingAccounts the source accounts containing all required field data + * @return Mono emitting a list of successfully upserted trading accounts, or an empty list if input is null/empty + */ + public Mono> upsertPortfolioTradingAccounts( + List investmentPortfolioTradingAccounts) { + + log.info("Upserting portfolio trading accounts: count={}", + investmentPortfolioTradingAccounts != null ? investmentPortfolioTradingAccounts.size() : 0); + + if (investmentPortfolioTradingAccounts == null || investmentPortfolioTradingAccounts.isEmpty()) { + return Mono.just(List.of()); + } + + return Flux.fromIterable(investmentPortfolioTradingAccounts) + .flatMap(this::upsertSingleTradingAccount) + .collectList(); + } + + /** + * Upserts a single portfolio trading account derived from an investment portfolio account. + * + *

This method: + *

    + *
  1. Resolves the internal portfolio UUID from the account's {@code portfolioExternalId}
  2. + *
  3. Builds a {@link PortfolioTradingAccountRequest} directly from the source account and resolved UUID
  4. + *
  5. Creates or updates the trading account via the API
  6. + *
+ * + *

Errors are logged and result in an empty Mono to prevent failing the entire batch. + * + * @param investmentPortfolioTradingAccount the source account containing all required field data + * @return Mono emitting the upserted trading account, or empty if processing fails + */ + private Mono upsertSingleTradingAccount( + InvestmentPortfolioTradingAccount investmentPortfolioTradingAccount) { + + String externalAccountId = investmentPortfolioTradingAccount.getAccountExternalId(); + log.debug("Processing trading account: externalAccountId={}", externalAccountId); + + return fetchPortfolioInternalId(investmentPortfolioTradingAccount.getPortfolioExternalId()) + .map(portfolioUuid -> buildTradingAccountRequest(investmentPortfolioTradingAccount, portfolioUuid)) + .flatMap(this::upsertPortfolioTradingAccount) + .onErrorResume(throwable -> { + log.warn("Skipping trading account due to error: externalAccountId={}", externalAccountId, throwable); + return Mono.empty(); + }); + } + + /** + * Fetches the internal portfolio UUID using the portfolio external ID. + * + *

Queries the portfolio service to retrieve the internal UUID + * corresponding to the given external ID. + * + * @param portfolioExternalId the external ID of the portfolio + * @return Mono emitting the resolved portfolio UUID + * @throws IllegalStateException if the portfolio cannot be found or multiple results are returned + */ + private Mono fetchPortfolioInternalId(String portfolioExternalId) { + if (portfolioExternalId == null) { + log.warn("Cannot fetch portfolio internal ID: portfolioExternalId is null"); + return Mono.empty(); + } + + log.debug("Fetching portfolio internal ID: externalId={}", portfolioExternalId); + + return listExistingPortfolios(portfolioExternalId) + .map(PortfolioList::getUuid) + .doOnSuccess(uuid -> log.debug("Resolved portfolio UUID={} for externalId={}", + uuid, portfolioExternalId)) + .doOnError(throwable -> log.error( + "Failed to fetch portfolio internal ID: externalId={}", + portfolioExternalId, throwable)); + } + + /** + * Builds a {@link PortfolioTradingAccountRequest} directly from an {@link InvestmentPortfolioTradingAccount} + * and a resolved portfolio UUID, skipping the intermediate {@link PortfolioTradingAccount} domain object. + * + *

Maps the following fields: + *

    + *
  • {@code accountExternalId} → {@code externalAccountId}
  • + *
  • {@code isDefault} → {@code isDefault}
  • + *
  • {@code isInternal} → {@code isInternal}
  • + *
  • {@code productTypeExternalId} → {@code accountId}
  • + *
  • resolved {@code portfolioUuid} → {@code portfolio}
  • + *
+ * + * @param investmentPortfolioTradingAccount the source account containing field data + * @param portfolioUuid the resolved internal portfolio UUID + * @return the constructed {@link PortfolioTradingAccountRequest} + */ + private PortfolioTradingAccountRequest buildTradingAccountRequest( + InvestmentPortfolioTradingAccount investmentPortfolioTradingAccount, UUID portfolioUuid) { + + return new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId(investmentPortfolioTradingAccount.getAccountId()) + .externalAccountId(investmentPortfolioTradingAccount.getAccountExternalId()) + .isDefault(investmentPortfolioTradingAccount.getIsDefault()) + .isInternal(investmentPortfolioTradingAccount.getIsInternal()); + } + + /** + * Upserts a portfolio trading account using the provided request. + * + *

Implementation of upsert pattern: + *

    + *
  1. Searches for an existing trading account by external account ID
  2. + *
  3. If found, patches the existing account with the new values
  4. + *
  5. If not found, creates a new trading account
  6. + *
+ * + * @param request the trading account request containing all necessary fields + * @return Mono emitting the created or updated trading account + */ + public Mono upsertPortfolioTradingAccount(PortfolioTradingAccountRequest request) { + + return listExistingPortfolioTradingAccounts(request) + .flatMap(existing -> patchExistingPortfolioTradingAccount(existing, request)) + .switchIfEmpty(Mono.defer(() -> createPortfolioTradingAccount(request))) + .doOnSuccess(account -> log.info( + "Successfully upserted portfolio trading account: uuid={}, externalAccountId={}", + account.getUuid(), request.getExternalAccountId())) + .doOnError(throwable -> log.error( + "Failed to upsert portfolio trading account: externalAccountId={}", + request.getExternalAccountId(), throwable)); + } + + /** + * Patches an existing portfolio trading account with updated values. + * + *

If the patch operation fails (e.g., due to validation errors or conflicts), + * falls back to returning the existing account to preserve data integrity + * and prevent batch failures. + * + * @param existing the existing trading account to update + * @param request the request containing updated values + * @return Mono emitting the updated trading account, or the existing account if patch fails + */ + private Mono patchExistingPortfolioTradingAccount( + PortfolioTradingAccount existing, + PortfolioTradingAccountRequest request) { + + String uuid = existing.getUuid().toString(); + log.debug("Patching portfolio trading account: uuid={}, externalAccountId={}", + uuid, request.getExternalAccountId()); + + return portfolioTradingAccountsApi.patchPortfolioTradingAccount(uuid, request) + .doOnSuccess(updated -> log.info( + "Successfully patched portfolio trading account: uuid={}", updated.getUuid())) + .doOnError(throwable -> logPortfolioTradingAccountError("PATCH", "uuid", uuid, throwable)) + .onErrorResume(WebClientResponseException.class, ex -> { + log.info("Using existing portfolio trading account due to patch failure: uuid={}", uuid); + return Mono.just(existing); + }); + } + + /** + * Creates a new portfolio trading account via the API. + * + *

Called during the upsert flow when no existing trading account is found. + * + * @param request the request containing all required trading account details + * @return Mono emitting the newly created trading account + */ + public Mono createPortfolioTradingAccount(PortfolioTradingAccountRequest request) { + + return portfolioTradingAccountsApi.createPortfolioTradingAccount(request) + .doOnSuccess(account -> log.info( + "Created portfolio trading account: uuid={}, externalAccountId={}", + account.getUuid(), request.getExternalAccountId())) + .doOnError(throwable -> logPortfolioTradingAccountError( + "CREATE", "externalAccountId", request.getExternalAccountId(), throwable)); + } + + /** + * Lists existing portfolio trading accounts matching the external account ID in the request. + * + *

Validates the result to ensure data consistency: + *

    + *
  • Returns empty if no matching account is found
  • + *
  • Returns the single matching account if exactly one result is found
  • + *
  • Returns an error if multiple accounts are found (indicates a data setup issue)
  • + *
+ * + * @param request the request containing the external account ID to search for + * @return Mono emitting the matching trading account, or empty if not found + * @throws IllegalStateException if more than one trading account is found with the same external account ID + */ + private Mono listExistingPortfolioTradingAccounts( + PortfolioTradingAccountRequest request) { + + String externalAccountId = request.getExternalAccountId(); + + return portfolioTradingAccountsApi.listPortfolioTradingAccounts( + 1, null, null, externalAccountId, null, null, null) + .doOnSuccess(accounts -> log.debug( + "List portfolio trading accounts query completed: externalAccountId={}, found={} results", + externalAccountId, accounts != null ? accounts.getResults().size() : 0)) + .doOnError(throwable -> log.error( + "Failed to list existing portfolio trading accounts: externalAccountId={}", + externalAccountId, throwable)) + .flatMap(accounts -> validateAndExtractPortfolioTradingAccount(accounts, externalAccountId)); + } + + /** + * Validates and extracts a single trading account from paginated search results. + * + *

Enforces data consistency: + *

    + *
  • Returns empty for no results — expected for new accounts
  • + *
  • Returns the account for exactly one result
  • + *
  • Returns an error for multiple results — indicates a data setup issue
  • + *
+ * + * @param accounts the paginated list of trading accounts returned by the API + * @param externalAccountId the external account ID used in the search, for logging purposes + * @return Mono emitting the single matching trading account, or empty if no results found + * @throws IllegalStateException if multiple trading accounts are found with the same external account ID + */ + private Mono validateAndExtractPortfolioTradingAccount( + PaginatedPortfolioTradingAccountList accounts, + String externalAccountId) { + + if (accounts == null || CollectionUtils.isEmpty(accounts.getResults())) { + log.info("No existing portfolio trading account found: externalAccountId={}", externalAccountId); + return Mono.empty(); + } + + int resultCount = accounts.getResults().size(); + if (resultCount > 1) { + log.error("Data setup issue: Found {} portfolio trading accounts with externalAccountId={}, " + + "expected at most 1. Please review trading account configuration.", + resultCount, externalAccountId); + return Mono.error(new IllegalStateException( + String.format("Data setup issue: Found %d portfolio trading accounts with externalAccountId=%s, " + + "expected at most 1. Please review trading account configuration.", + resultCount, externalAccountId))); + } + + PortfolioTradingAccount existingAccount = accounts.getResults().getFirst(); + log.info("Found existing portfolio trading account: uuid={}, externalAccountId={}", + existingAccount.getUuid(), externalAccountId); + return Mono.just(existingAccount); + } + + /** + * Logs errors occurring during portfolio trading account operations. + * + *

Provides enhanced error context for {@link WebClientResponseException}, + * including HTTP status code and response body. For other exceptions, logs + * basic error information. + * + * @param operation a short description of the operation that failed (e.g., "PATCH", "CREATE") + * @param idLabel the label for the identifier (e.g., "uuid", "externalAccountId") + * @param idValue the value of the identifier + * @param throwable the exception that occurred + */ + private void logPortfolioTradingAccountError(String operation, String idLabel, String idValue, Throwable throwable) { + if (throwable instanceof WebClientResponseException ex) { + log.warn("Portfolio trading account {} failed: {}={}, status={}, body={}", + operation, idLabel, idValue, ex.getStatusCode(), ex.getResponseBodyAsString()); + } else { + log.error("Portfolio trading account {} failed: {}={}", operation, idLabel, idValue, throwable); + } + } + /** * Logs portfolio creation errors with detailed information about the failure. * diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseService.java index fafadc3e3..b0f332c5f 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseService.java @@ -64,14 +64,14 @@ public Mono createAsset(com.backbase.strea } public Mono patchAsset(Asset existAsset, - com.backbase.stream.investment.Asset asset) { + com.backbase.stream.investment.Asset asset, Map categoryIdByCode) { String assetUuid = existAsset.getUuid().toString(); log.info( "Starting asset update: assetUuid={}, assetName='{}', logoFile='{}'", assetUuid, asset.getName(), asset.getLogo()); - - return Mono.defer(() -> Mono.just(patchAsset(assetUuid, null, asset.getLogoFile()))) + OASAssetRequestDataRequest assetRequestDataRequest = assetMapper.mapAsset(asset, categoryIdByCode); + return Mono.defer(() -> Mono.just(patchAsset(assetUuid, assetRequestDataRequest, asset.getLogoFile()))) .map(patchedAsset -> { log.info( "Logo attached successfully to asset:assetUuid={}, assetName='{}', logoFile='{}'", assetUuid, diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java index ca16528a1..5733244d3 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java @@ -6,6 +6,7 @@ import com.backbase.investment.api.service.v1.model.MarketRequest; import com.backbase.stream.investment.service.resttemplate.InvestmentRestAssetUniverseService; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -99,7 +100,8 @@ void getOrCreateAsset_assetExists() { ArgumentMatchers.any(), ArgumentMatchers.any())) .thenReturn(Mono.just(existingAsset)); - Mockito.when(investmentRestAssetUniverseService.patchAsset(ArgumentMatchers.any(), ArgumentMatchers.any())) + Mockito.when(investmentRestAssetUniverseService.patchAsset(ArgumentMatchers.any(), ArgumentMatchers.any(), + HashMap.newHashMap(1))) .thenReturn(Mono.just(asset)); Mockito.when(investmentRestAssetUniverseService.createAsset( ArgumentMatchers.any(), diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java new file mode 100644 index 000000000..bbe891209 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java @@ -0,0 +1,381 @@ +package com.backbase.stream.investment.service; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.backbase.investment.api.service.v1.InvestmentProductsApi; +import com.backbase.investment.api.service.v1.PaymentsApi; +import com.backbase.investment.api.service.v1.PortfolioApi; +import com.backbase.investment.api.service.v1.PortfolioTradingAccountsApi; +import com.backbase.investment.api.service.v1.model.PaginatedPortfolioListList; +import com.backbase.investment.api.service.v1.model.PaginatedPortfolioTradingAccountList; +import com.backbase.investment.api.service.v1.model.PortfolioList; +import com.backbase.investment.api.service.v1.model.PortfolioTradingAccount; +import com.backbase.investment.api.service.v1.model.PortfolioTradingAccountRequest; +import com.backbase.investment.api.service.v1.model.StatusA3dEnum; +import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties; +import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class InvestmentPortfolioServiceTest { + + private InvestmentProductsApi productsApi; + private PortfolioApi portfolioApi; + private PaymentsApi paymentsApi; + private PortfolioTradingAccountsApi portfolioTradingAccountsApi; + private InvestmentIngestionConfigurationProperties config; + private InvestmentPortfolioService service; + + @BeforeEach + void setUp() { + productsApi = Mockito.mock(InvestmentProductsApi.class); + portfolioApi = Mockito.mock(PortfolioApi.class); + paymentsApi = Mockito.mock(PaymentsApi.class); + portfolioTradingAccountsApi = Mockito.mock(PortfolioTradingAccountsApi.class); + config = Mockito.mock(InvestmentIngestionConfigurationProperties.class); + when(config.getPortfolioActivationPastMonths()).thenReturn(6); + service = new InvestmentPortfolioService( + productsApi, portfolioApi, paymentsApi, portfolioTradingAccountsApi, config); + } + + // ----------------------------------------------------------------------- + // upsertPortfolioTradingAccount — patch path + // ----------------------------------------------------------------------- + + @Test + void upsertPortfolioTradingAccount_existingAccount_patchesAndReturns() { + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getExternalAccountId()).thenReturn("EXT-001"); + + PortfolioTradingAccount patched = Mockito.mock(PortfolioTradingAccount.class); + when(patched.getUuid()).thenReturn(existingUuid); + when(patched.getExternalAccountId()).thenReturn("EXT-001"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .externalAccountId("EXT-001") + .accountId("ACC-001") + .portfolio(portfolioUuid) + .isDefault(false) + .isInternal(false); + + PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() + .results(List.of(existing)); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-001"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(accountList)); + + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + eq(existingUuid.toString()), any())) + .thenReturn(Mono.just(patched)); + + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(a -> a.getUuid().equals(existingUuid)) + .verifyComplete(); + } + + @Test + void upsertPortfolioTradingAccount_noExistingAccount_createsNew() { + UUID newUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + when(created.getUuid()).thenReturn(newUuid); + when(created.getExternalAccountId()).thenReturn("EXT-002"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .externalAccountId("EXT-002") + .accountId("ACC-002") + .portfolio(portfolioUuid) + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-002"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount((request))) + .thenReturn(Mono.just(created)); + + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(a -> a.getUuid().equals(newUuid)) + .verifyComplete(); + } + + @Test + void upsertPortfolioTradingAccount_patchFails_withWebClientException_fallsBackToExisting() { + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getExternalAccountId()).thenReturn("EXT-003"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .externalAccountId("EXT-003") + .accountId("ACC-003") + .portfolio(portfolioUuid) + .isDefault(false) + .isInternal(false); + + PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() + .results(List.of(existing)); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-003"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(accountList)); + + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + (existingUuid.toString()), (request))) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.BAD_REQUEST.value(), "Bad Request", + HttpHeaders.EMPTY, null, StandardCharsets.UTF_8))); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(any())) + .thenReturn(Mono.just(existing)); + + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(a -> a.getUuid().equals(existingUuid)) + .verifyComplete(); + } + + @Test + void upsertPortfolioTradingAccount_patchFails_withNonWebClientException_propagatesError() { + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getExternalAccountId()).thenReturn("EXT-004"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .externalAccountId("EXT-004") + .accountId("ACC-004") + .portfolio(portfolioUuid) + .isDefault(false) + .isInternal(false); + + PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() + .results(List.of(existing)); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-004"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(accountList)); + + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + (existingUuid.toString()), (request))) + .thenReturn(Mono.error(new RuntimeException("Unexpected error"))); + + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectErrorMatches(e -> e instanceof RuntimeException + && e.getMessage().equals("Unexpected error")) + .verify(); + } + + @Test + void upsertPortfolioTradingAccount_multipleExistingAccounts_returnsError() { + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount acc1 = Mockito.mock(PortfolioTradingAccount.class); + when(acc1.getUuid()).thenReturn(UUID.randomUUID()); + PortfolioTradingAccount acc2 = Mockito.mock(PortfolioTradingAccount.class); + when(acc2.getUuid()).thenReturn(UUID.randomUUID()); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .externalAccountId("EXT-DUP") + .accountId("ACC-DUP") + .portfolio(portfolioUuid) + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-DUP"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList() + .results(List.of(acc1, acc2)))); + + // Defensive stub — prevents NPE if switchIfEmpty is accidentally reached + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(any())) + .thenReturn(Mono.error(new IllegalStateException("should not be called"))); + + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectErrorMatches(e -> e instanceof IllegalStateException + && e.getMessage().contains("Found 2 portfolio trading accounts")) + .verify(); + } + + // ----------------------------------------------------------------------- + // upsertPortfolioTradingAccounts — batch resilience + // ----------------------------------------------------------------------- + + @Test + void upsertPortfolioTradingAccounts_nullInput_returnsEmptyList() { + StepVerifier.create(service.upsertPortfolioTradingAccounts(null)) + .expectNext(List.of()) + .verifyComplete(); + } + + @Test + void upsertPortfolioTradingAccounts_emptyInput_returnsEmptyList() { + StepVerifier.create(service.upsertPortfolioTradingAccounts(List.of())) + .expectNext(List.of()) + .verifyComplete(); + } + + @Test + void upsertPortfolioTradingAccounts_singleFailure_doesNotStopBatch() { + UUID portfolioUuid1 = UUID.randomUUID(); + UUID portfolioUuid2 = UUID.randomUUID(); + String externalId1 = "PORTFOLIO-EXT-001"; + String externalId2 = "PORTFOLIO-EXT-002"; + + // Mock portfolio lookups — uses externalId, not uuid setter + mockPortfolioFound(externalId1, portfolioUuid1); + mockPortfolioFound(externalId2, portfolioUuid2); + + // Account 1: list returns empty → create fails + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-FAIL"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + when(portfolioTradingAccountsApi.createPortfolioTradingAccount( + argThat(r -> r != null && "EXT-FAIL".equals(r.getExternalAccountId())))) + .thenReturn(Mono.error(new RuntimeException("Create failed"))); + + // Account 2: list returns empty → create succeeds + UUID createdUuid = UUID.randomUUID(); + PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + when(created.getUuid()).thenReturn(createdUuid); + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-OK"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + when(portfolioTradingAccountsApi.createPortfolioTradingAccount( + argThat(r -> r != null && "EXT-OK".equals(r.getExternalAccountId())))) + .thenReturn(Mono.just(created)); + + List input = List.of( + buildTradingAccountInput("EXT-FAIL", externalId1), + buildTradingAccountInput("EXT-OK", externalId2) + ); + + StepVerifier.create(service.upsertPortfolioTradingAccounts(input)) + .expectNextMatches(list -> list.size() == 1 && list.get(0).getUuid().equals(createdUuid)) + .verifyComplete(); + } + + @Test + void upsertPortfolioTradingAccounts_allFail_returnsEmptyList() { + String externalId = "PORTFOLIO-EXT-ALL-FAIL"; + UUID portfolioUuid = UUID.randomUUID(); + + mockPortfolioFound(externalId, portfolioUuid); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-ALL-FAIL"), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("List failed"))); + + List input = List.of( + buildTradingAccountInput("EXT-ALL-FAIL", externalId) + ); + + StepVerifier.create(service.upsertPortfolioTradingAccounts(input)) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + } + + // ----------------------------------------------------------------------- + // createPortfolioTradingAccount — direct creation + // ----------------------------------------------------------------------- + + @Test + void createPortfolioTradingAccount_success_returnsCreatedAccount() { + UUID portfolioUuid = UUID.randomUUID(); + UUID newUuid = UUID.randomUUID(); + + PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + when(created.getUuid()).thenReturn(newUuid); + when(created.getExternalAccountId()).thenReturn("EXT-NEW"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .externalAccountId("EXT-NEW") + .accountId("ACC-NEW") + .portfolio(portfolioUuid) + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount((request))) + .thenReturn(Mono.just(created)); + + StepVerifier.create(service.createPortfolioTradingAccount(request)) + .expectNextMatches(a -> a.getUuid().equals(newUuid)) + .verifyComplete(); + } + + @Test + void createPortfolioTradingAccount_apiFails_propagatesError() { + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .externalAccountId("EXT-ERR") + .accountId("ACC-ERR") + .portfolio(portfolioUuid) + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount((request))) + .thenReturn(Mono.error(new RuntimeException("Creation failed"))); + + StepVerifier.create(service.createPortfolioTradingAccount(request)) + .expectErrorMatches(e -> e instanceof RuntimeException + && e.getMessage().equals("Creation failed")) + .verify(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private PortfolioList buildPortfolioList(UUID portfolioUuid, String externalId, OffsetDateTime activated) { + PortfolioList portfolio = Mockito.mock(PortfolioList.class); + when(portfolio.getUuid()).thenReturn(portfolioUuid); + when(portfolio.getExternalId()).thenReturn(externalId); + when(portfolio.getActivated()).thenReturn(activated); + when(portfolio.getStatus()).thenReturn(StatusA3dEnum.ACTIVE); + return portfolio; + } + + private void mockPortfolioFound(String externalId, UUID portfolioUuid) { + PortfolioList portfolioList = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); + PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); + when(paginatedList.getResults()).thenReturn(List.of(portfolioList)); + + when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), + isNull(), eq(externalId), isNull(), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(paginatedList)); + } + + private InvestmentPortfolioTradingAccount buildTradingAccountInput(String externalAccountId, + String portfolioExternalId) { + return InvestmentPortfolioTradingAccount.builder() + .accountExternalId(externalAccountId) + .portfolioExternalId(portfolioExternalId) + .accountId("ACC-" + externalAccountId) + .isDefault(false) + .isInternal(false) + .build(); + } +} \ No newline at end of file