diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec80d01f..d67bc5924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog All notable changes to this project will be documented in this file. +## [10.10.0] +### Changed +- Updated stream-investment to be able to seed intraday prices to all the assets +- fix valuation and activation date in investment + ## [10.9.0] ### Changed - Add product-document links in investment diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceService.java index cb05e17f6..a438c999c 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceService.java @@ -17,7 +17,9 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.ThreadLocalRandom; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; @@ -42,6 +44,10 @@ @RequiredArgsConstructor public class InvestmentIntradayAssetPriceService { + static final int LIST_ASSET_PAGE_SIZE = 50; + private static final List ASSET_EXPAND_FIELDS = List.of("market", "latest_price"); + private static final String ASSET_LIST_FIELDS = "uuid,market,latest_price"; + private final AssetUniverseApi assetUniverseApi; /** @@ -62,55 +68,31 @@ public Mono> ingestIntradayPrices() { @Nonnull private Mono>> generateIntradayPrices() { log.info("Generating Intraday Prices for Assets"); - return assetUniverseApi.listAssetsWithResponseSpec( - null, null, null, null, - List.of("market", "latest_price"), - null, null, - "uuid,market,latest_price", - null, null, null, null, null, - null, null, null, null - ) // Above API returns custom projection with market and latest price expanded only, hence needs a custom return type. - .bodyToMono(PaginatedExpandedAssetList.class) - .flatMap(paginatedAssetList -> { - - if (paginatedAssetList.getCount() == 0) { + AtomicInteger pageCounter = new AtomicInteger(0); + AtomicInteger assetCount = new AtomicInteger(0); + + return listAssetsPage(LIST_ASSET_PAGE_SIZE, 0) + .expand(page -> { + if (page.getNext() == null) { + return Mono.empty(); + } + int nextOffset = pageCounter.incrementAndGet() * LIST_ASSET_PAGE_SIZE; + return listAssetsPage(LIST_ASSET_PAGE_SIZE, nextOffset); + }) + .flatMap(page -> { + List results = + Objects.requireNonNullElse(page.getResults(), List.of()); + assetCount.addAndGet(results.size()); + return Flux.fromIterable(results); + }) + .flatMap(this::createIntradayPricesForAssetIfPresent) + .collectList() + .doOnSuccess(results -> { + if (assetCount.get() == 0) { log.warn("No assets found with latest prices to generate intraday prices"); - return Mono.just(List.>of()); + } else { + log.debug("Processed {} assets for intraday price generation", assetCount.get()); } - - return Flux.fromIterable(paginatedAssetList.getResults()) - .flatMap(assetWithMarketAndLatestPrice -> { - List requests = - generateIntradayPricesForAsset(assetWithMarketAndLatestPrice); - - log.debug("Generated intraday price requests: {}", requests.size()); - log.trace("Generated intraday price requests: {}", requests); - - if (requests.isEmpty()) { - return Mono.empty(); - } - - return assetUniverseApi.bulkCreateIntradayAssetPrice(requests, null, null, null) - .collectList() - .doOnSuccess(created -> - log.info( - "Successfully triggered creation of {} intraday prices for asset ({})", - requests.size(), - assetWithMarketAndLatestPrice.uuid() - ) - ) - .doOnError(WebClientResponseException.class, ex -> - log.error( - "Failed to create intraday prices for asset ({}): status={}, body={}", - assetWithMarketAndLatestPrice.uuid(), - ex.getStatusCode(), - ex.getResponseBodyAsString(), - ex - ) - ) - .onErrorResume(e -> Mono.empty()); - }) - .collectList(); }) .doOnError(error -> { if (error instanceof WebClientResponseException w) { @@ -127,7 +109,50 @@ private Mono>> generateIntradayPrices() { ); } }); + } + + private Mono listAssetsPage(int limit, int offset) { + return assetUniverseApi.listAssetsWithResponseSpec( + null, null, null, null, + ASSET_EXPAND_FIELDS, + null, null, + ASSET_LIST_FIELDS, + null, limit, null, null, offset, + null, null, null, null) + // Above API returns custom projection with market and latest price expanded only, hence needs a custom return type. + .bodyToMono(PaginatedExpandedAssetList.class); + } + + private Mono> createIntradayPricesForAssetIfPresent( + AssetWithMarketAndLatestPrice assetWithMarketAndLatestPrice) { + List requests = generateIntradayPricesForAsset(assetWithMarketAndLatestPrice); + + log.debug("Generated intraday price requests: {}", requests.size()); + log.trace("Generated intraday price requests: {}", requests); + + if (requests.isEmpty()) { + return Mono.empty(); + } + return assetUniverseApi.bulkCreateIntradayAssetPrice(requests, null, null, null) + .collectList() + .doOnSuccess(created -> + log.info( + "Successfully triggered creation of {} intraday prices for asset ({})", + requests.size(), + assetWithMarketAndLatestPrice.uuid() + ) + ) + .doOnError(WebClientResponseException.class, ex -> + log.error( + "Failed to create intraday prices for asset ({}): status={}, body={}", + assetWithMarketAndLatestPrice.uuid(), + ex.getStatusCode(), + ex.getResponseBodyAsString(), + ex + ) + ) + .onErrorResume(e -> Mono.empty()); } /** diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationService.java index f16957325..892fd2de6 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationService.java @@ -298,6 +298,17 @@ private static Double calculateTrades(List p return portfolioPositions.stream().map(a -> a.getPrice() * a.getShares()).reduce(0.0, Double::sum); } + private static OASAllocationCreateRequest buildCashOnlyAllocation(double amount, LocalDate valuationDate) { + double roundedAmount = roundPrice(amount); + return new OASAllocationCreateRequest() + .cashActive(roundedAmount) + .tradeTotal(0.0) + .balance(roundedAmount) + .invested(roundedAmount) + .earnings(0.0) + .valuationDate(valuationDate); + } + public Mono createDepositAllocation(Deposit deposit) { String portfolioId = deposit.getPortfolio().toString(); LocalDate valuationDate = Optional.ofNullable(deposit.getCompletedAt()).map(OffsetDateTime::toLocalDate) @@ -305,10 +316,8 @@ public Mono createDepositAllocation(Deposit deposit) { return getAllocations(portfolioId, valuationDate.minusDays(4), valuationDate.plusDays(5), 10) .filter(Predicate.not(l -> l.stream().filter(a -> CollectionUtils.isEmpty(a.getPositions())) .toList().isEmpty())) - .switchIfEmpty(upsertAllocations(portfolioId, List.of( - new OASAllocationCreateRequest() - .cashActive(deposit.getAmount()) - .valuationDate(valuationDate))) + .switchIfEmpty(upsertAllocations(portfolioId, List.of(buildCashOnlyAllocation(deposit.getAmount(), + valuationDate))) .onErrorResume(ex -> Mono.empty()) ) .onErrorResume(ex -> { 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 aabca5fc5..e4bbddb16 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 @@ -24,6 +24,7 @@ import com.backbase.stream.investment.model.InvestmentPortfolio; import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Collection; import java.util.List; import java.util.Map; @@ -181,19 +182,20 @@ private Mono patchPortfolio( String uuid = existingProduct.getUuid().toString(); List associatedClients = getClients(investmentArrangement, clientsByLeId); - PatchedPortfolioUpdateRequest patchedPortfolioUpdateRequest = new PatchedPortfolioUpdateRequest() - .product(investmentArrangement.getInvestmentProductId()) - .externalId(investmentArrangement.getExternalId()) - .name(investmentArrangement.getName()) - .clients(associatedClients) - .status(StatusA3dEnum.ACTIVE) - .activated(OffsetDateTime.now().minusMonths(config.getPortfolio().getActivationPastMonths())) - .extraData(investmentArrangement.getExtraData()); - log.debug("Attempting to patch existing portfolio: uuid={}, externalId={}, extraData={}", uuid, investmentArrangement.getExternalId(), investmentArrangement.getExtraData()); - return portfolioApi.patchPortfolio(uuid, null, null, null, patchedPortfolioUpdateRequest) + return resolveActivationDate(existingProduct) + .map(activated -> new PatchedPortfolioUpdateRequest() + .product(investmentArrangement.getInvestmentProductId()) + .externalId(investmentArrangement.getExternalId()) + .name(investmentArrangement.getName()) + .clients(associatedClients) + .status(StatusA3dEnum.ACTIVE) + .activated(activated) + .extraData(investmentArrangement.getExtraData())) + .flatMap(patchedPortfolioUpdateRequest -> portfolioApi.patchPortfolio( + uuid, null, null, null, patchedPortfolioUpdateRequest)) .doOnSuccess(updated -> { log.info("Successfully patched existing investment portfolio: uuid={}", updated.getUuid()); investmentArrangement.setInvestmentProductId(updated.getUuid()); @@ -238,7 +240,7 @@ private Mono createNewPortfolio(InvestmentArrangement investmentA .currency(Optional.ofNullable(investmentArrangement.getCurrency()) .orElse(config.getPortfolio().getDefaultCurrency())) .status(StatusA3dEnum.ACTIVE) - .activated(OffsetDateTime.now().minusMonths(config.getPortfolio().getActivationPastMonths())) + .activated(computePortfolioActivationDate()) .extraData(investmentArrangement.getExtraData()); log.debug("Creating investment portfolio: externalId={}, name={}, extraData={}", @@ -275,14 +277,7 @@ private static List getClients(InvestmentArrangement investmentArrangement public Mono upsertDeposits(InvestmentPortfolio investmentPortfolio) { PortfolioList portfolio = investmentPortfolio.getPortfolio(); double initAmount = investmentPortfolio.getInitialCashOrDefault(config.getDeposit().getDefaultAmount()); - return paymentsApi.listDeposits(null, null, null, null, null, - null, portfolio.getUuid(), null, null, null) - .filter(Objects::nonNull) - // Use flatMap with Mono.justOrEmpty() to safely handle null results without NPE, - // ensuring switchIfEmpty fallback triggers for both null and empty deposit lists. - .flatMap(paginatedResult -> - Mono.justOrEmpty(paginatedResult.getResults()) - .filter(list -> !list.isEmpty())) + return listPortfolioDeposits(portfolio.getUuid()) .flatMap(deposits -> { double deposited = deposits.stream().mapToDouble(Deposit::getAmount).sum(); double remaining = initAmount - deposited; @@ -294,20 +289,29 @@ public Mono upsertDeposits(InvestmentPortfolio investmentPortfolio) { .onErrorResume(ex -> Mono.just(new Deposit() .portfolio(portfolio.getUuid()) .amount(initAmount) - .completedAt(portfolio.getActivated().plusDays(2)) + .completedAt(portfolio.getActivated()) ) ); } + private Mono> listPortfolioDeposits(UUID portfolioUuid) { + return paymentsApi.listDeposits(null, null, null, null, null, + null, portfolioUuid, null, null, null) + .filter(Objects::nonNull) + .flatMap(paginatedResult -> + Mono.justOrEmpty(paginatedResult.getResults()) + .filter(list -> !list.isEmpty())); + } + @Nonnull private Mono createDeposit(PortfolioList portfolio, double defaultAmount) { + OffsetDateTime activated = portfolio.getActivated(); return paymentsApi.createDeposit(new DepositRequest() .portfolio(portfolio.getUuid()) .provider(config.getDeposit().getProvider()) - .reason(UUID.randomUUID().toString()) .status(Status08fEnum.COMPLETED) - .transactedAt(portfolio.getActivated().plusDays(1)) - .completedAt(portfolio.getActivated().plusDays(2)) + .transactedAt(activated) + .completedAt(activated) .amount(defaultAmount) .depositType(DepositTypeEnum.TRANSFER) .reason("Initial deposit") @@ -718,6 +722,37 @@ private void logPortfolioTradingAccountError(String operation, String idLabel, S } } + private OffsetDateTime computePortfolioActivationDate() { + return OffsetDateTime.now(ZoneOffset.UTC).minusMonths(config.getPortfolio().getActivationPastMonths()); + } + + /** + * Resolves the activation timestamp for an existing portfolio. + * + *

Priority: earliest deposit date, then the portfolio's current activation date, + * then {@link #computePortfolioActivationDate()} for portfolios without either. + */ + private Mono resolveActivationDate(PortfolioList existingPortfolio) { + UUID portfolioUuid = existingPortfolio.getUuid(); + OffsetDateTime fallback = Optional.ofNullable(existingPortfolio.getActivated()) + .orElseGet(this::computePortfolioActivationDate); + + return listPortfolioDeposits(portfolioUuid) + .map(deposits -> earliestDepositDate(deposits).orElse(fallback)) + .defaultIfEmpty(fallback) + .onErrorResume(ex -> { + log.warn("Failed to resolve activation date for portfolio: uuid={}", portfolioUuid, ex); + return Mono.just(fallback); + }); + } + + private Optional earliestDepositDate(List deposits) { + return deposits.stream() + .map(deposit -> Optional.ofNullable(deposit.getCompletedAt()).orElse(deposit.getTransactedAt())) + .filter(Objects::nonNull) + .min(OffsetDateTime::compareTo); + } + /** * Logs portfolio creation errors with detailed information about the failure. * diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java index 41e6c0827..ef7a96e9b 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java @@ -3,9 +3,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -18,6 +20,7 @@ import com.backbase.stream.investment.model.ExpandedMarket; import com.backbase.stream.investment.model.PaginatedExpandedAssetList; import com.backbase.stream.investment.service.InvestmentIntradayAssetPriceService.Ohlc; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -53,6 +56,8 @@ @DisplayName("InvestmentIntradayAssetPriceService") class InvestmentIntradayAssetPriceServiceTest { + private static final int PAGE_SIZE = InvestmentIntradayAssetPriceService.LIST_ASSET_PAGE_SIZE; + private AssetUniverseApi assetUniverseApi; private InvestmentIntradayAssetPriceService service; @@ -237,20 +242,12 @@ class IngestIntradayPricesTests { @DisplayName("zero assets (count == 0) — returns empty list, bulkCreate never called") void ingestIntradayPrices_zeroAssets_returnsEmptyList() { // Arrange - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); PaginatedExpandedAssetList emptyPage = PaginatedExpandedAssetList.builder() .count(0) .results(List.of()) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(emptyPage)); + stubListAssetsPage(0, emptyPage); // Act & Assert StepVerifier.create(service.ingestIntradayPrices()) @@ -267,20 +264,12 @@ void ingestIntradayPrices_assetWithNullLatestPrice_skipped() { AssetWithMarketAndLatestPrice assetWithNullLatestPrice = new AssetWithMarketAndLatestPrice(UUID.randomUUID(), buildExpandedMarket(), null); - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() .count(1) .results(List.of(assetWithNullLatestPrice)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); // Act & Assert StepVerifier.create(service.ingestIntradayPrices()) @@ -299,20 +288,12 @@ void ingestIntradayPrices_assetWithNullPreviousClosePrice_skipped() { AssetWithMarketAndLatestPrice asset = new AssetWithMarketAndLatestPrice(UUID.randomUUID(), buildExpandedMarket(), latestPriceWithNullClose); - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() .count(1) .results(List.of(asset)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); // Act & Assert StepVerifier.create(service.ingestIntradayPrices()) @@ -330,20 +311,12 @@ void ingestIntradayPrices_singleValidAsset_bulkCreateCalledAndResultReturned() { AssetWithMarketAndLatestPrice asset = buildValidAsset(assetUuid, 150.0); GroupResult groupResult = buildGroupResult(); - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() .count(1) .results(List.of(asset)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) .thenReturn(Flux.just(groupResult)); @@ -366,20 +339,12 @@ void ingestIntradayPrices_singleValidAsset_requestsHaveCorrectTypeUuidAnd15Candl AssetWithMarketAndLatestPrice asset = buildValidAsset(assetUuid, 50.0); GroupResult groupResult = buildGroupResult(); - var responseSpec = mock(WebClient.ResponseSpec.class); - PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + var page = PaginatedExpandedAssetList.builder() .count(1) .results(List.of(asset)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) .thenAnswer(invocation -> { List requests = invocation.getArgument(0); @@ -407,20 +372,12 @@ void ingestIntradayPrices_multipleValidAssets_resultsFlattened() { GroupResult groupResult1 = buildGroupResult(); GroupResult groupResult2 = buildGroupResult(); - var responseSpec = mock(WebClient.ResponseSpec.class); - PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + var page = PaginatedExpandedAssetList.builder() .count(2) .results(List.of(asset1, asset2)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) // bodyToMono with custom type - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) .thenReturn(Flux.just(groupResult1)) .thenReturn(Flux.just(groupResult2)); @@ -440,20 +397,12 @@ void ingestIntradayPrices_mixedAssets_onlyValidAssetProcessed() { new AssetWithMarketAndLatestPrice(UUID.randomUUID(), buildExpandedMarket(), null); GroupResult groupResult = buildGroupResult(); - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() .count(2) .results(List.of(validAsset, invalidAsset)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) .thenReturn(Flux.just(groupResult)); @@ -471,20 +420,12 @@ void ingestIntradayPrices_bulkCreateFailsWithWebClientException_errorSwallowedPi // Arrange AssetWithMarketAndLatestPrice asset = buildValidAsset(UUID.randomUUID(), 120.0); - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() .count(1) .results(List.of(asset)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) .thenReturn(Flux.error(notFound())); @@ -500,20 +441,12 @@ void ingestIntradayPrices_bulkCreateFailsWithRuntimeException_errorSwallowedPipe // Arrange AssetWithMarketAndLatestPrice asset = buildValidAsset(UUID.randomUUID(), 75.0); - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() .count(1) .results(List.of(asset)) .build(); - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.just(page)); + stubListAssetsPage(0, page); when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) .thenReturn(Flux.error(new RuntimeException("unexpected failure"))); @@ -524,19 +457,47 @@ void ingestIntradayPrices_bulkCreateFailsWithRuntimeException_errorSwallowedPipe } @Test - @DisplayName("listAssetsWithResponseSpec fails with WebClientResponseException — error propagated") - void ingestIntradayPrices_listAssetsFails_webClientError_errorPropagated() { + @DisplayName("multiple pages — assets from every page are processed") + void ingestIntradayPrices_multiplePages_allAssetsProcessed() { // Arrange - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + AssetWithMarketAndLatestPrice asset1 = buildValidAsset(UUID.randomUUID(), 100.0); + AssetWithMarketAndLatestPrice asset2 = buildValidAsset(UUID.randomUUID(), 200.0); + GroupResult groupResult1 = buildGroupResult(); + GroupResult groupResult2 = buildGroupResult(); + + PaginatedExpandedAssetList page1 = PaginatedExpandedAssetList.builder() + .count(2) + .next(URI.create("http://example.com/assets?offset=50")) + .results(List.of(asset1)) + .build(); + PaginatedExpandedAssetList page2 = PaginatedExpandedAssetList.builder() + .count(2) + .results(List.of(asset2)) + .build(); + + stubListAssetsPage(0, page1); + stubListAssetsPage(PAGE_SIZE, page2); + when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult1)) + .thenReturn(Flux.just(groupResult2)); - when(assetUniverseApi.listAssetsWithResponseSpec( + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).hasSize(2)) + .verifyComplete(); + + verify(assetUniverseApi, times(2)).listAssetsWithResponseSpec( isNull(), isNull(), isNull(), isNull(), any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.error(notFound())); + isNull(), eq(PAGE_SIZE), isNull(), isNull(), any(Integer.class), + isNull(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("listAssetsWithResponseSpec fails with WebClientResponseException — error propagated") + void ingestIntradayPrices_listAssetsFails_webClientError_errorPropagated() { + // Arrange + stubListAssetsPageError(0, notFound()); // Act & Assert StepVerifier.create(service.ingestIntradayPrices()) @@ -549,16 +510,7 @@ void ingestIntradayPrices_listAssetsFails_webClientError_errorPropagated() { @DisplayName("listAssetsWithResponseSpec fails with generic RuntimeException — error propagated") void ingestIntradayPrices_listAssetsFails_runtimeError_errorPropagated() { // Arrange - WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); - - when(assetUniverseApi.listAssetsWithResponseSpec( - isNull(), isNull(), isNull(), isNull(), - any(), isNull(), isNull(), any(), - isNull(), isNull(), isNull(), isNull(), isNull(), - isNull(), isNull(), isNull(), isNull())) - .thenReturn(responseSpec); - when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) - .thenReturn(Mono.error(new RuntimeException("connection refused"))); + stubListAssetsPageError(0, new RuntimeException("connection refused")); // Act & Assert StepVerifier.create(service.ingestIntradayPrices()) @@ -572,6 +524,36 @@ void ingestIntradayPrices_listAssetsFails_runtimeError_errorPropagated() { // Helpers // ========================================================================= + /** + * Stubs a single list-assets page at the given offset. + */ + private void stubListAssetsPage(int offset, PaginatedExpandedAssetList page) { + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), eq(PAGE_SIZE), isNull(), isNull(), eq(offset), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + } + + /** + * Stubs a failed list-assets page at the given offset. + */ + private void stubListAssetsPageError(int offset, Throwable error) { + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), eq(PAGE_SIZE), isNull(), isNull(), eq(offset), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.error(error)); + } + /** * Builds a 404 NOT_FOUND {@link WebClientResponseException}. */ diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java index d1b259ed5..ccd2d5619 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; import com.backbase.investment.api.service.v1.AllocationsApi; import com.backbase.investment.api.service.v1.AssetUniverseApi; @@ -43,6 +44,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -243,12 +245,12 @@ void createDepositAllocation_allPositionsNonEmpty_createsCashAllocationAndReturn } @Test - @DisplayName("no allocations returned — switchIfEmpty triggers, creates cash-active allocation") + @DisplayName("no allocations returned — switchIfEmpty triggers, creates cash-active allocation with all fields") void createDepositAllocation_noAllocations_createsCashAllocationAndReturnsDeposit() { // Arrange UUID portfolioUuid = UUID.randomUUID(); LocalDate completedAt = LocalDate.now().minusDays(1); - Deposit deposit = buildDeposit(portfolioUuid, completedAt, 3_000d); + Deposit deposit = buildDeposit(portfolioUuid, completedAt, 10_000d); PaginatedOASPortfolioAllocationList emptyPage = mock(PaginatedOASPortfolioAllocationList.class); when(emptyPage.getResults()).thenReturn(List.of()); @@ -258,8 +260,10 @@ void createDepositAllocation_noAllocations_createsCashAllocationAndReturnsDeposi .thenReturn(Mono.just(emptyPage)); OASPortfolioAllocation created = mock(OASPortfolioAllocation.class); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OASAllocationCreateRequest.class); when(allocationsApi.createPortfolioAllocation( - eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull())) + eq(portfolioUuid.toString()), requestCaptor.capture(), isNull(), isNull(), isNull())) .thenReturn(Mono.just(created)); // Act & Assert @@ -267,8 +271,13 @@ void createDepositAllocation_noAllocations_createsCashAllocationAndReturnsDeposi .expectNextMatches(d -> d == deposit) .verifyComplete(); - verify(allocationsApi) - .createPortfolioAllocation(eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull()); + OASAllocationCreateRequest request = requestCaptor.getValue(); + assertThat(request.getCashActive()).isEqualTo(10_000d); + assertThat(request.getTradeTotal()).isEqualTo(0.0); + assertThat(request.getBalance()).isEqualTo(10_000d); + assertThat(request.getInvested()).isEqualTo(10_000d); + assertThat(request.getEarnings()).isEqualTo(0.0); + assertThat(request.getValuationDate()).isEqualTo(completedAt); } @Test 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 index 412518b35..394a0e7e3 100644 --- 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 @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -46,7 +47,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.web.reactive.function.client.WebClientResponseException; @@ -80,10 +80,10 @@ class InvestmentPortfolioServiceTest { @BeforeEach void setUp() { - productsApi = Mockito.mock(InvestmentProductsApi.class); - portfolioApi = Mockito.mock(PortfolioApi.class); - paymentsApi = Mockito.mock(PaymentsApi.class); - portfolioTradingAccountsApi = Mockito.mock(PortfolioTradingAccountsApi.class); + productsApi = mock(InvestmentProductsApi.class); + portfolioApi = mock(PortfolioApi.class); + paymentsApi = mock(PaymentsApi.class); + portfolioTradingAccountsApi = mock(PortfolioTradingAccountsApi.class); config = new IngestConfigProperties(); service = new InvestmentPortfolioService( portfolioApi, paymentsApi, portfolioTradingAccountsApi, config); @@ -116,11 +116,11 @@ void upsertPortfolioTradingAccount_existingAccount_patchesAndReturns() { UUID existingUuid = UUID.randomUUID(); UUID portfolioUuid = UUID.randomUUID(); - PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); when(existing.getUuid()).thenReturn(existingUuid); when(existing.getExternalAccountId()).thenReturn("EXT-001"); - PortfolioTradingAccount patched = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount patched = mock(PortfolioTradingAccount.class); when(patched.getUuid()).thenReturn(existingUuid); when(patched.getExternalAccountId()).thenReturn("EXT-001"); @@ -159,7 +159,7 @@ void upsertPortfolioTradingAccount_noExistingAccount_createsNew() { UUID newUuid = UUID.randomUUID(); UUID portfolioUuid = UUID.randomUUID(); - PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount created = mock(PortfolioTradingAccount.class); when(created.getUuid()).thenReturn(newUuid); when(created.getExternalAccountId()).thenReturn("EXT-002"); @@ -193,7 +193,7 @@ void upsertPortfolioTradingAccount_patchFails_withWebClientException_fallsBackTo UUID existingUuid = UUID.randomUUID(); UUID portfolioUuid = UUID.randomUUID(); - PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); when(existing.getUuid()).thenReturn(existingUuid); when(existing.getExternalAccountId()).thenReturn("EXT-003"); @@ -235,7 +235,7 @@ void upsertPortfolioTradingAccount_patchFails_withNonWebClientException_propagat UUID existingUuid = UUID.randomUUID(); UUID portfolioUuid = UUID.randomUUID(); - PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); when(existing.getUuid()).thenReturn(existingUuid); when(existing.getExternalAccountId()).thenReturn("EXT-004"); @@ -270,9 +270,9 @@ void upsertPortfolioTradingAccount_multipleExistingAccounts_returnsError() { // Arrange UUID portfolioUuid = UUID.randomUUID(); - PortfolioTradingAccount acc1 = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount acc1 = mock(PortfolioTradingAccount.class); when(acc1.getUuid()).thenReturn(UUID.randomUUID()); - PortfolioTradingAccount acc2 = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount acc2 = mock(PortfolioTradingAccount.class); when(acc2.getUuid()).thenReturn(UUID.randomUUID()); PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() @@ -380,7 +380,7 @@ void upsertPortfolioTradingAccounts_singleFailure_doesNotStopBatch() { // Account 2: list returns empty → create succeeds UUID createdUuid = UUID.randomUUID(); - PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount created = mock(PortfolioTradingAccount.class); when(created.getUuid()).thenReturn(createdUuid); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( eq(1), isNull(), isNull(), eq("ACC-OK-002"), isNull(), isNull(), isNull())) @@ -467,7 +467,7 @@ void createPortfolioTradingAccount_success_returnsCreatedAccount() { UUID portfolioUuid = UUID.randomUUID(); UUID newUuid = UUID.randomUUID(); - PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + PortfolioTradingAccount created = mock(PortfolioTradingAccount.class); when(created.getUuid()).thenReturn(newUuid); when(created.getExternalAccountId()).thenReturn("EXT-NEW"); @@ -547,7 +547,7 @@ void upsertInvestmentPortfolios_existingPortfolio_patchesAndReturns() { PortfolioList patched = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); PortfolioList fallbackCreated = buildPortfolioList(UUID.randomUUID(), externalId, OffsetDateTime.now().minusMonths(6)); - PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList paginatedList = mock(PaginatedPortfolioListList.class); when(paginatedList.getResults()).thenReturn(List.of(existing)); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -557,6 +557,7 @@ void upsertInvestmentPortfolios_existingPortfolio_patchesAndReturns() { .thenReturn(Mono.just(fallbackCreated)); when(portfolioApi.patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), any())) .thenReturn(Mono.just(patched)); + mockNoExistingDeposits(); Map> clientsByLeExternalId = Map.of(leExternalId, List.of(clientUuid)); @@ -569,6 +570,87 @@ void upsertInvestmentPortfolios_existingPortfolio_patchesAndReturns() { verify(portfolioApi, never()).createPortfolio(any(), any(), any(), any()); } + @Test + @DisplayName("patch portfolio — preserves existing activated date when no deposits exist") + void upsertInvestmentPortfolios_patch_preservesExistingActivatedWithoutDeposits() { + UUID portfolioUuid = UUID.randomUUID(); + UUID productId = UUID.randomUUID(); + String externalId = "PORTFOLIO-EXT-KEEP-ACTIVATED"; + String leExternalId = "LE-KEEP-ACTIVATED"; + UUID clientUuid = UUID.randomUUID(); + OffsetDateTime existingActivated = OffsetDateTime.now().minusMonths(6); + + InvestmentArrangement arrangement = buildArrangement(externalId, "Keep Activated Portfolio", productId, + leExternalId); + PortfolioList existing = buildPortfolioList(portfolioUuid, externalId, existingActivated); + PortfolioList patched = buildPortfolioList(portfolioUuid, externalId, existingActivated); + + PaginatedPortfolioListList paginatedList = mock(PaginatedPortfolioListList.class); + when(paginatedList.getResults()).thenReturn(List.of(existing)); + when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), + isNull(), eq(externalId), isNull(), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(paginatedList)); + when(portfolioApi.patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), any())) + .thenReturn(Mono.just(patched)); + mockNoExistingDeposits(); + + StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, + Map.of(leExternalId, List.of(clientUuid)))) + .expectNextMatches(p -> portfolioUuid.equals(p.getUuid())) + .verifyComplete(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(PatchedPortfolioUpdateRequest.class); + verify(portfolioApi).patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), + requestCaptor.capture()); + assertThat(requestCaptor.getValue().getActivated()).isEqualTo(existingActivated); + } + + @Test + @DisplayName("patch portfolio — aligns activated with earliest existing deposit date") + void upsertInvestmentPortfolios_patch_alignsActivatedWithFirstDeposit() { + UUID portfolioUuid = UUID.randomUUID(); + UUID productId = UUID.randomUUID(); + String externalId = "PORTFOLIO-EXT-ACTIVATED-DEPOSIT"; + String leExternalId = "LE-ACTIVATED-DEPOSIT"; + UUID clientUuid = UUID.randomUUID(); + OffsetDateTime depositDate = OffsetDateTime.now().minusMonths(6); + + InvestmentArrangement arrangement = buildArrangement(externalId, "Activated Deposit Portfolio", productId, + leExternalId); + PortfolioList existing = buildPortfolioList(portfolioUuid, externalId, depositDate.minusDays(2)); + PortfolioList patched = buildPortfolioList(portfolioUuid, externalId, depositDate); + + PaginatedPortfolioListList paginatedList = mock(PaginatedPortfolioListList.class); + when(paginatedList.getResults()).thenReturn(List.of(existing)); + when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), + isNull(), eq(externalId), isNull(), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(paginatedList)); + when(portfolioApi.patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), any())) + .thenReturn(Mono.just(patched)); + + Deposit existingDeposit = mock(Deposit.class); + when(existingDeposit.getCompletedAt()).thenReturn(depositDate); + PaginatedDepositList depositList = mock(PaginatedDepositList.class); + when(depositList.getResults()).thenReturn(List.of(existingDeposit)); + when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(depositList)); + + StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, + Map.of(leExternalId, List.of(clientUuid)))) + .expectNextMatches(p -> portfolioUuid.equals(p.getUuid())) + .verifyComplete(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(PatchedPortfolioUpdateRequest.class); + verify(portfolioApi).patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), + requestCaptor.capture()); + assertThat(requestCaptor.getValue().getActivated()).isEqualTo(depositDate); + } + @Test @DisplayName("no existing portfolio — creates and returns new portfolio") void upsertInvestmentPortfolios_noExistingPortfolio_createsNew() { @@ -581,7 +663,7 @@ void upsertInvestmentPortfolios_noExistingPortfolio_createsNew() { InvestmentArrangement arrangement = buildArrangement(externalId, "New Portfolio", productId, leExternalId); - PaginatedPortfolioListList emptyList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList emptyList = mock(PaginatedPortfolioListList.class); when(emptyList.getResults()).thenReturn(List.of()); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -617,7 +699,7 @@ void upsertInvestmentPortfolios_create_forwardsExtraData() { leExternalId); when(arrangement.getExtraData()).thenReturn(extraData); - PaginatedPortfolioListList emptyList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList emptyList = mock(PaginatedPortfolioListList.class); when(emptyList.getResults()).thenReturn(List.of()); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -656,7 +738,7 @@ void upsertInvestmentPortfolios_patch_forwardsExtraData() { PortfolioList existing = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); PortfolioList patched = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); - PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList paginatedList = mock(PaginatedPortfolioListList.class); when(paginatedList.getResults()).thenReturn(List.of(existing)); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -664,6 +746,7 @@ void upsertInvestmentPortfolios_patch_forwardsExtraData() { .thenReturn(Mono.just(paginatedList)); when(portfolioApi.patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), any())) .thenReturn(Mono.just(patched)); + mockNoExistingDeposits(); StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, Map.of(leExternalId, List.of(clientUuid)))) @@ -690,7 +773,7 @@ void upsertInvestmentPortfolios_create_nullExtraDataWhenAbsent() { leExternalId); when(arrangement.getExtraData()).thenReturn(null); - PaginatedPortfolioListList emptyList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList emptyList = mock(PaginatedPortfolioListList.class); when(emptyList.getResults()).thenReturn(List.of()); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -725,7 +808,7 @@ void upsertInvestmentPortfolios_patchFails_withWebClientException_fallsBackToExi InvestmentArrangement arrangement = buildArrangement(externalId, "Patch Fail Portfolio", productId, leExternalId); PortfolioList existing = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); - PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList paginatedList = mock(PaginatedPortfolioListList.class); when(paginatedList.getResults()).thenReturn(List.of(existing)); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -736,6 +819,7 @@ void upsertInvestmentPortfolios_patchFails_withWebClientException_fallsBackToExi .thenReturn(Mono.error(WebClientResponseException.create( HttpStatus.UNPROCESSABLE_ENTITY.value(), "Unprocessable Entity", HttpHeaders.EMPTY, "patch error".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))); + mockNoExistingDeposits(); Map> clientsByLeExternalId = Map.of(leExternalId, List.of(clientUuid)); @@ -760,7 +844,7 @@ void upsertInvestmentPortfolios_multipleExistingPortfolios_returnsError() { PortfolioList p1 = buildPortfolioList(UUID.randomUUID(), externalId, OffsetDateTime.now().minusMonths(6)); PortfolioList p2 = buildPortfolioList(UUID.randomUUID(), externalId, OffsetDateTime.now().minusMonths(6)); - PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList paginatedList = mock(PaginatedPortfolioListList.class); when(paginatedList.getResults()).thenReturn(List.of(p1, p2)); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -815,14 +899,14 @@ void upsertPortfolios_multipleArrangements_returnsAllPortfolios() { InvestmentArrangement arrangement2 = buildArrangement(externalId2, "Portfolio 2", productId, leExternalId); // Stub listPortfolios per externalId — both return empty (no existing portfolio) - PaginatedPortfolioListList emptyList1 = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList emptyList1 = mock(PaginatedPortfolioListList.class); when(emptyList1.getResults()).thenReturn(List.of()); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId1), isNull(), isNull(), eq(1), isNull(), isNull(), isNull(), isNull())) .thenReturn(Mono.just(emptyList1)); - PaginatedPortfolioListList emptyList2 = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList emptyList2 = mock(PaginatedPortfolioListList.class); when(emptyList2.getResults()).thenReturn(List.of()); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId2), isNull(), isNull(), eq(1), @@ -869,7 +953,7 @@ void upsertPortfolios_singleFailure_skipsFailedArrangement() { InvestmentArrangement failArrangement = buildArrangement( failExternalId, "Fail Portfolio", productId, leExternalId); - PaginatedPortfolioListList emptyList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList emptyList = mock(PaginatedPortfolioListList.class); when(emptyList.getResults()).thenReturn(List.of()); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(successExternalId), isNull(), isNull(), eq(1), @@ -908,7 +992,7 @@ void upsertPortfolios_success_mapsCashAndWithdrawalAmount() { when(arrangement.getInitialCash()).thenReturn(BigDecimal.valueOf(25_000)); when(arrangement.getWithdrawalAmount()).thenReturn(BigDecimal.valueOf(1_500)); - PaginatedPortfolioListList emptyList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList emptyList = mock(PaginatedPortfolioListList.class); when(emptyList.getResults()).thenReturn(List.of()); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), isNull(), eq(externalId), isNull(), isNull(), eq(1), @@ -923,8 +1007,8 @@ void upsertPortfolios_success_mapsCashAndWithdrawalAmount() { StepVerifier.create(service.upsertPortfolios( List.of(arrangement), Map.of(leExternalId, List.of(clientUuid)))) .expectNextMatches(list -> list.size() == 1 - && BigDecimal.valueOf(25_000).equals(list.getFirst().getInitialCash()) - && BigDecimal.valueOf(1_500).equals(list.getFirst().getWithdrawalAmount())) + && BigDecimal.valueOf(25_000).compareTo(list.getFirst().getInitialCash()) == 0 + && BigDecimal.valueOf(1_500).compareTo(list.getFirst().getWithdrawalAmount()) == 0) .verifyComplete(); } } @@ -962,7 +1046,7 @@ void upsertDeposits_noExistingDeposits_createsDefaultDeposit() { isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) .thenReturn(Mono.just(new PaginatedDepositList().results(List.of()))); - Deposit created = Mockito.mock(Deposit.class); + Deposit created = mock(Deposit.class); when(created.getAmount()).thenReturn(10_000d); when(paymentsApi.createDeposit(any(DepositRequest.class))) .thenReturn(Mono.just(created)); @@ -984,14 +1068,14 @@ void upsertDeposits_existingDepositsLessThanDefault_topsUpRemainingAmount() { OffsetDateTime.now().minusMonths(6)); InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder().portfolio(portfolio).build(); - Deposit existingDeposit = Mockito.mock(Deposit.class); + Deposit existingDeposit = mock(Deposit.class); when(existingDeposit.getAmount()).thenReturn(4_000d); when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) .thenReturn(Mono.just(new PaginatedDepositList().results(List.of(existingDeposit)))); - Deposit topUpDeposit = Mockito.mock(Deposit.class); + Deposit topUpDeposit = mock(Deposit.class); when(topUpDeposit.getAmount()).thenReturn(6_000d); when(paymentsApi.createDeposit(any(DepositRequest.class))) .thenReturn(Mono.just(topUpDeposit)); @@ -1013,14 +1097,14 @@ void upsertDeposits_existingDepositsEqualToDefault_doesNotCreateNewDeposit() { OffsetDateTime.now().minusMonths(6)); InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder().portfolio(portfolio).build(); - Deposit existingDeposit = Mockito.mock(Deposit.class); + Deposit existingDeposit = mock(Deposit.class); when(existingDeposit.getAmount()).thenReturn(10_000d); when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) .thenReturn(Mono.just(new PaginatedDepositList().results(List.of(existingDeposit)))); - Deposit fallbackDeposit = Mockito.mock(Deposit.class); + Deposit fallbackDeposit = mock(Deposit.class); when(paymentsApi.createDeposit(any())).thenReturn(Mono.just(fallbackDeposit)); // Act & Assert StepVerifier.create(service.upsertDeposits(investmentPortfolio)) @@ -1043,7 +1127,7 @@ void upsertDeposits_nullDepositResultList_createsDefaultDeposit() { isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) .thenReturn(Mono.just(new PaginatedDepositList().results(null))); - Deposit created = Mockito.mock(Deposit.class); + Deposit created = mock(Deposit.class); when(created.getAmount()).thenReturn(10_000d); when(paymentsApi.createDeposit(any(DepositRequest.class))) .thenReturn(Mono.just(created)); @@ -1161,7 +1245,7 @@ void upsertWithdrawals_existingWithdrawalsPartial_topsUpRemainingAmount() { .portfolio(portfolio) .build(); - IntegrationWithdrawalList existing = Mockito.mock(IntegrationWithdrawalList.class); + IntegrationWithdrawalList existing = mock(IntegrationWithdrawalList.class); when(existing.getAmount()).thenReturn(200d); when(paymentsApi.listWithdrawals(isNull(), isNull(), isNull(), isNull(), isNull(), @@ -1194,7 +1278,7 @@ void upsertWithdrawals_existingWithdrawalsFull_returnsExistingWithoutCreating() .portfolio(portfolio) .build(); - IntegrationWithdrawalList existing = Mockito.mock(IntegrationWithdrawalList.class); + IntegrationWithdrawalList existing = mock(IntegrationWithdrawalList.class); when(existing.getAmount()).thenReturn(500d); when(existing.getPortfolio()).thenReturn(portfolioUuid); when(existing.getCompletedAt()).thenReturn(completedAt); @@ -1325,7 +1409,7 @@ void upsertInvestmentProducts_unknownProductType_returnsError() { // Arrange InvestmentArrangement arrangement = buildArrangementWithProductType( "ARR-UNKNOWN-TYPE", "Unknown Type Arrangement", "UNKNOWN_TYPE"); - InvestmentData investmentData = Mockito.mock(InvestmentData.class); + InvestmentData investmentData = mock(InvestmentData.class); // Act & Assert StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) @@ -1342,14 +1426,14 @@ void upsertInvestmentProducts_selfTradingType_existingProduct_patchesAndReturns( InvestmentArrangement arrangement = buildArrangementWithProductType( externalId, "Self Trading Patch", ProductTypeEnum.SELF_TRADING.getValue()); - InvestmentData investmentData = Mockito.mock(InvestmentData.class); + InvestmentData investmentData = mock(InvestmentData.class); when(investmentData.getModelPortfolios()).thenReturn(List.of()); PortfolioProduct existingProduct = buildPortfolioProduct(productUuid, ProductTypeEnum.SELF_TRADING); PortfolioProduct patched = buildPortfolioProduct(productUuid, ProductTypeEnum.SELF_TRADING); PortfolioProduct fallbackCreated = buildPortfolioProduct(UUID.randomUUID(), ProductTypeEnum.SELF_TRADING); - PaginatedPortfolioProductList productList = Mockito.mock(PaginatedPortfolioProductList.class); + PaginatedPortfolioProductList productList = mock(PaginatedPortfolioProductList.class); when(productList.getResults()).thenReturn(List.of(existingProduct)); when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), @@ -1381,10 +1465,10 @@ void upsertInvestmentProducts_selfTradingType_noExistingProduct_createsNew() { InvestmentArrangement arrangement = buildArrangementWithProductType( externalId, "Self Trading New", ProductTypeEnum.SELF_TRADING.getValue()); - InvestmentData investmentData = Mockito.mock(InvestmentData.class); + InvestmentData investmentData = mock(InvestmentData.class); when(investmentData.getModelPortfolios()).thenReturn(List.of()); - PaginatedPortfolioProductList emptyList = Mockito.mock(PaginatedPortfolioProductList.class); + PaginatedPortfolioProductList emptyList = mock(PaginatedPortfolioProductList.class); when(emptyList.getResults()).thenReturn(List.of()); when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), @@ -1415,15 +1499,15 @@ void upsertInvestmentProducts_roboType_withModelPortfolio_createsProductWithMode InvestmentArrangement arrangement = buildArrangementWithProductType( externalId, "Robo Arrangement", ProductTypeEnum.ROBO_ADVISOR.getValue()); - ModelPortfolio modelPortfolio = Mockito.mock(ModelPortfolio.class); + ModelPortfolio modelPortfolio = mock(ModelPortfolio.class); when(modelPortfolio.getUuid()).thenReturn(modelUuid); when(modelPortfolio.getRiskLevel()).thenReturn(3); when(modelPortfolio.getProductTypeEnum()).thenReturn(ProductTypeEnum.ROBO_ADVISOR); - InvestmentData investmentData = Mockito.mock(InvestmentData.class); + InvestmentData investmentData = mock(InvestmentData.class); when(investmentData.getModelPortfolios()).thenReturn(List.of(modelPortfolio)); - PaginatedPortfolioProductList emptyList = Mockito.mock(PaginatedPortfolioProductList.class); + PaginatedPortfolioProductList emptyList = mock(PaginatedPortfolioProductList.class); when(emptyList.getResults()).thenReturn(List.of()); when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), eq(1), isNull(), isNull(), eq(3), isNull(), isNull(), any(), @@ -1451,7 +1535,7 @@ void upsertInvestmentProducts_roboType_noModelPortfolio_returnsError() { InvestmentArrangement arrangement = buildArrangementWithProductType( externalId, "Robo No Model", ProductTypeEnum.ROBO_ADVISOR.getValue()); - InvestmentData investmentData = Mockito.mock(InvestmentData.class); + InvestmentData investmentData = mock(InvestmentData.class); when(investmentData.getModelPortfolios()).thenReturn(List.of()); // Act & Assert @@ -1470,10 +1554,10 @@ void upsertInvestmentProducts_multipleArrangementsWithSameProductType_deduplicat InvestmentArrangement arr2 = buildArrangementWithProductType( "ARR-DEDUP-002", "Dedup 2", ProductTypeEnum.SELF_TRADING.getValue()); - InvestmentData investmentData = Mockito.mock(InvestmentData.class); + InvestmentData investmentData = mock(InvestmentData.class); when(investmentData.getModelPortfolios()).thenReturn(List.of()); - PaginatedPortfolioProductList emptyList = Mockito.mock(PaginatedPortfolioProductList.class); + PaginatedPortfolioProductList emptyList = mock(PaginatedPortfolioProductList.class); when(emptyList.getResults()).thenReturn(List.of()); when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), @@ -1500,12 +1584,12 @@ void upsertInvestmentProducts_patchFails_withWebClientException_fallsBackToExist InvestmentArrangement arrangement = buildArrangementWithProductType( "ARR-PATCH-FAIL", "Patch Fail", ProductTypeEnum.SELF_TRADING.getValue()); - InvestmentData investmentData = Mockito.mock(InvestmentData.class); + InvestmentData investmentData = mock(InvestmentData.class); when(investmentData.getModelPortfolios()).thenReturn(List.of()); PortfolioProduct existingProduct = buildPortfolioProduct(productUuid, ProductTypeEnum.SELF_TRADING); - PaginatedPortfolioProductList productList = Mockito.mock(PaginatedPortfolioProductList.class); + PaginatedPortfolioProductList productList = mock(PaginatedPortfolioProductList.class); when(productList.getResults()).thenReturn(List.of(existingProduct)); when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), @@ -1529,7 +1613,7 @@ void upsertInvestmentProducts_patchFails_withWebClientException_fallsBackToExist // @Test // @DisplayName("null arrangements list — throws NullPointerException immediately") // void upsertInvestmentProducts_nullArrangements_throwsNullPointerException() { -// InvestmentData investmentData = Mockito.mock(InvestmentData.class); +// InvestmentData investmentData = mock(InvestmentData.class); // StepVerifier.create(service.upsertInvestmentProducts(investmentData, null)) // .expectError(NullPointerException.class) // .verify(); @@ -1544,7 +1628,7 @@ void upsertInvestmentProducts_patchFails_withWebClientException_fallsBackToExist * Builds a mocked {@link PortfolioList} with the given UUID, externalId, and activation date. */ private PortfolioList buildPortfolioList(UUID portfolioUuid, String externalId, OffsetDateTime activated) { - PortfolioList portfolio = Mockito.mock(PortfolioList.class); + PortfolioList portfolio = mock(PortfolioList.class); when(portfolio.getUuid()).thenReturn(portfolioUuid); when(portfolio.getExternalId()).thenReturn(externalId); when(portfolio.getActivated()).thenReturn(activated); @@ -1552,13 +1636,21 @@ private PortfolioList buildPortfolioList(UUID portfolioUuid, String externalId, return portfolio; } + private void mockNoExistingDeposits() { + PaginatedDepositList emptyDeposits = mock(PaginatedDepositList.class); + when(emptyDeposits.getResults()).thenReturn(List.of()); + when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), any(UUID.class), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(emptyDeposits)); + } + /** * Builds a mocked {@link PortfolioProduct} with the given UUID and product type. * Model portfolio and advice engine are set to null for SELF_TRADING; callers * should override these stubs for non-SELF_TRADING types. */ private PortfolioProduct buildPortfolioProduct(UUID uuid, ProductTypeEnum productType) { - PortfolioProduct product = Mockito.mock(PortfolioProduct.class); + PortfolioProduct product = mock(PortfolioProduct.class); when(product.getUuid()).thenReturn(uuid); when(product.getProductType()).thenReturn(productType); when(product.getAdviceEngine()).thenReturn(null); @@ -1573,7 +1665,7 @@ private PortfolioProduct buildPortfolioProduct(UUID uuid, ProductTypeEnum produc */ private InvestmentArrangement buildArrangement(String externalId, String name, UUID productId, String legalEntityExternalId) { - InvestmentArrangement arrangement = Mockito.mock(InvestmentArrangement.class); + InvestmentArrangement arrangement = mock(InvestmentArrangement.class); when(arrangement.getExternalId()).thenReturn(externalId); when(arrangement.getName()).thenReturn(name); when(arrangement.getInvestmentProductId()).thenReturn(productId); @@ -1589,7 +1681,7 @@ private InvestmentArrangement buildArrangement(String externalId, String name, */ private InvestmentArrangement buildArrangementWithProductType(String externalId, String name, String productTypeValue) { - InvestmentArrangement arrangement = Mockito.mock(InvestmentArrangement.class); + InvestmentArrangement arrangement = mock(InvestmentArrangement.class); when(arrangement.getExternalId()).thenReturn(externalId); when(arrangement.getName()).thenReturn(name); when(arrangement.getProductTypeExternalId()).thenReturn(productTypeValue); @@ -1607,7 +1699,7 @@ private InvestmentArrangement buildArrangementWithProductType(String externalId, private void mockPortfolioFound(String externalId, UUID portfolioUuid) { PortfolioList portfolioList = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); - PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); + PaginatedPortfolioListList paginatedList = mock(PaginatedPortfolioListList.class); when(paginatedList.getResults()).thenReturn(List.of(portfolioList)); when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(),