diff --git a/CHANGELOG.md b/CHANGELOG.md index 39407034c..a6abd7162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [10.6.0] +### Added +- Added withdrawals to investment service ingestion + ## [10.4.0] ### Changed - Upgrade `customer-profile-api` version from `1.17.1` to `2.6.0`. diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/IngestConfigProperties.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/IngestConfigProperties.java index 982202c8c..e142c48e8 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/IngestConfigProperties.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/IngestConfigProperties.java @@ -41,6 +41,7 @@ public class IngestConfigProperties { private PortfolioConfig portfolio = new PortfolioConfig(); private AllocationConfig allocation = new AllocationConfig(); private DepositConfig deposit = new DepositConfig(); + private WithdrawalConfig withdrawal = new WithdrawalConfig(); private AssetConfig asset = new AssetConfig(); private AssessmentConfig assessment = new AssessmentConfig(); @@ -114,7 +115,33 @@ public static class DepositConfig { } // ------------------------------------------------------------------------- - // Deposit + // Withdrawal + // ------------------------------------------------------------------------- + + /** + * Settings that govern the automatic seed withdrawal created for portfolios during ingestion. + * + *

Withdrawals are only created when {@code defaultAmount} is greater than zero, or when an + * explicit {@code withdrawalAmount} is set on the {@code InvestmentPortfolio}. + */ + @Data + public static class WithdrawalConfig { + + /** + * The payment provider identifier sent with every withdrawal request. Set this to the real + * provider name for non-mock environments. + */ + private String provider = null; + + /** + * The monetary amount used as the withdrawal amount when no explicit value is set on the + * portfolio. Defaults to {@code 500.0} (5% of {@code DEFAULT_INIT_CASH}). + */ + private double defaultAmount = DEFAULT_INIT_CASH * 0.05d; + } + + // ------------------------------------------------------------------------- + // Asset // ------------------------------------------------------------------------- /** diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java index 3cda31b88..16f542d49 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java @@ -19,6 +19,7 @@ public class InvestmentArrangement { private String currency; private String productPortfolioName; private BigDecimal initialCash; + private BigDecimal withdrawalAmount; private UUID investmentProductId; private List legalEntityIds; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/InvestmentPortfolio.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/InvestmentPortfolio.java index ea85ed093..3717c4b50 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/InvestmentPortfolio.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/InvestmentPortfolio.java @@ -16,10 +16,16 @@ public class InvestmentPortfolio { private PortfolioList portfolio; private BigDecimal initialCash; + private BigDecimal withdrawalAmount; public double getInitialCashOrDefault(double defaultAmount) { return Optional.ofNullable(initialCash).map(BigDecimal::doubleValue) .orElse(defaultAmount); } + public double getWithdrawalAmountOrDefault(double defaultAmount) { + return Optional.ofNullable(withdrawalAmount).map(BigDecimal::doubleValue) + .orElse(defaultAmount); + } + } 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 3421c950b..8a60b01eb 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 @@ -108,6 +108,7 @@ public Mono executeTask(InvestmentTask streamTask) { .flatMap(this::upsertPortfolioTradingAccounts) .flatMap(this::upsertInvestmentPortfolioDeposits) .flatMap(this::upsertPortfoliosAllocations) + .flatMap(this::upsertInvestmentPortfolioWithdrawals) .doOnSuccess(completedTask -> { streamTask.setState(State.COMPLETED); log.info("Successfully completed investment saga: taskId={}, taskName={}, state={}", @@ -136,6 +137,41 @@ private Mono upsertInvestmentPortfolioDeposits(InvestmentTask in .map(_ -> investmentTask); } + /** + * Upserts withdrawals for all investment portfolios in the task. + * + *

    + *
  1. Iterates over every portfolio tracked in the task
  2. + *
  3. Calls {@link com.backbase.stream.investment.service.InvestmentPortfolioService#upsertWithdrawals} + * for each — portfolios with a zero or null withdrawal amount are silently skipped
  4. + *
  5. Per-portfolio errors are caught, logged as warnings, and skipped so that the rest of + * the batch is unaffected
  6. + *
+ * + * @param investmentTask the task containing the portfolios to process + * @return Mono emitting the unchanged task after all withdrawals have been processed + */ + private Mono upsertInvestmentPortfolioWithdrawals(InvestmentTask investmentTask) { + List portfolios = + Objects.requireNonNullElse(investmentTask.getData().getPortfolios(), List.of()); + log.info("Starting upsert of investment portfolio withdrawals: taskId={}, portfolioCount={}", + investmentTask.getId(), portfolios.size()); + if (portfolios.isEmpty()) { + log.warn("No portfolios found for withdrawal upsert — skipping: taskId={}", investmentTask.getId()); + return Mono.just(investmentTask); + } + return Flux.fromIterable(portfolios) + .flatMap(portfolio -> investmentPortfolioService.upsertWithdrawals(portfolio) + .onErrorResume(throwable -> { + log.warn("Failed to upsert withdrawal for portfolio: portfolioUuid={}, taskId={}", + portfolio.getPortfolio() != null ? portfolio.getPortfolio().getUuid() : "unknown", + investmentTask.getId(), throwable); + return Mono.empty(); + })) + .collectList() + .map(_ -> investmentTask); + } + /** * Rollback is not implemented for investment saga. * 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 c95337ced..0006816e7 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 @@ -7,6 +7,9 @@ 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; +import com.backbase.investment.api.service.v1.model.IntegrationWithdrawalCreate; +import com.backbase.investment.api.service.v1.model.IntegrationWithdrawalCreateRequest; +import com.backbase.investment.api.service.v1.model.IntegrationWithdrawalList; import com.backbase.investment.api.service.v1.model.IntegrationPortfolioCreateRequest; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioTradingAccountList; import com.backbase.investment.api.service.v1.model.PatchedPortfolioUpdateRequest; @@ -15,6 +18,7 @@ import com.backbase.investment.api.service.v1.model.PortfolioTradingAccountRequest; import com.backbase.investment.api.service.v1.model.Status08fEnum; import com.backbase.investment.api.service.v1.model.StatusA3dEnum; +import com.backbase.investment.api.service.v1.model.StatusDa8Enum; import com.backbase.stream.configuration.IngestConfigProperties; import com.backbase.stream.investment.InvestmentArrangement; import com.backbase.stream.investment.model.InvestmentPortfolio; @@ -74,6 +78,7 @@ public Mono> upsertPortfolios(List log.debug( "Successfully upserted investment portfolio: portfolioUuid={}, externalId={}, name={}", @@ -314,6 +319,111 @@ private Mono createDeposit(PortfolioList portfolio, double defaultAmoun }); } + /** + * Creates or updates a withdrawal for the given investment portfolio. + * + *

Implements the same idempotent upsert pattern as {@link #upsertDeposits}: + *

    + *
  1. Skips processing entirely when the configured withdrawal amount is {@code <= 0}
  2. + *
  3. Queries existing withdrawals for the portfolio
  4. + *
  5. If the sum of existing withdrawals is less than the target amount, creates an + * additional withdrawal for the remaining difference
  6. + *
  7. If the target has already been met, returns the last existing withdrawal
  8. + *
  9. If no withdrawals exist, creates one for the full target amount
  10. + *
  11. On error, returns a synthetic {@link IntegrationWithdrawalCreate} to allow + * downstream allocation steps to proceed
  12. + *
+ * + * @param investmentPortfolio the portfolio to process (must not be null) + * @return Mono emitting the created or existing withdrawal, or empty if amount is {@code <= 0} + */ + public Mono upsertWithdrawals(InvestmentPortfolio investmentPortfolio) { + PortfolioList portfolio = investmentPortfolio.getPortfolio(); + double withdrawalAmount = investmentPortfolio.getWithdrawalAmountOrDefault( + config.getWithdrawal().getDefaultAmount()); + + if (withdrawalAmount <= 0) { + log.info("Skipping withdrawal for portfolio: uuid={}, withdrawalAmount={}", + portfolio.getUuid(), withdrawalAmount); + return Mono.empty(); + } + + log.debug("Listing existing withdrawals for portfolio: uuid={}, targetWithdrawalAmount={}", + portfolio.getUuid(), withdrawalAmount); + return paymentsApi.listWithdrawals(null, null, null, null, null, + null, portfolio.getUuid(), 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 withdrawal lists. + .flatMap(paginatedResult -> + Mono.justOrEmpty(paginatedResult.getResults()) + .filter(list -> !list.isEmpty())) + .flatMap(withdrawals -> { + double withdrawn = withdrawals.stream().mapToDouble(IntegrationWithdrawalList::getAmount).sum(); + double remaining = withdrawalAmount - withdrawn; + log.debug("Portfolio withdrawal check: uuid={}, existing={}, target={}, remaining={}", + portfolio.getUuid(), withdrawn, withdrawalAmount, remaining); + if (remaining > 0) { + return createWithdrawal(portfolio, remaining); + } + IntegrationWithdrawalList last = withdrawals.getLast(); + return Mono.just(new IntegrationWithdrawalCreate() + .portfolio(last.getPortfolio()) + .amount(last.getAmount()) + .completedAt(last.getCompletedAt())); + }) + .switchIfEmpty(Mono.defer(() -> createWithdrawal(portfolio, withdrawalAmount))) + .onErrorResume(ex -> { + if (ex instanceof WebClientResponseException wce) { + log.error("listWithdrawals API call failed for portfolio: uuid={}, status={}, body={}", + portfolio.getUuid(), wce.getStatusCode(), wce.getResponseBodyAsString(), wce); + } else { + log.error("Failed to process withdrawal for portfolio: uuid={}", portfolio.getUuid(), ex); + } + return Mono.just(new IntegrationWithdrawalCreate() + .portfolio(portfolio.getUuid()) + .amount(withdrawalAmount) + .completedAt(portfolio.getActivated().plusDays(4))); + }); + } + + /** + * Creates a new withdrawal for the given portfolio via the payments API. + * + *

The withdrawal is created with: + *

    + *
  • Status {@code COMPLETED}
  • + *
  • Completion date set to portfolio activation date + 3 days
  • + *
  • Provider from configuration
  • + *
+ * + * @param portfolio the portfolio to create the withdrawal for + * @param amount the withdrawal amount + * @return Mono emitting the created withdrawal + */ + @Nonnull + private Mono createWithdrawal(PortfolioList portfolio, double amount) { + return paymentsApi.createWithdrawal(new IntegrationWithdrawalCreateRequest() + .portfolio(portfolio.getUuid()) + .provider(config.getWithdrawal().getProvider()) + .status(StatusDa8Enum.COMPLETED) + .completedAt(portfolio.getActivated().plusDays(3)) + .amount(amount) + ) + .doOnSuccess(withdrawal -> log.info("Created withdrawal {} for portfolio {}", + withdrawal.getUuid(), portfolio.getUuid())) + .doOnError(throwable -> { + if (throwable instanceof WebClientResponseException ex) { + log.warn( + "Portfolio withdrawal create failed: uuid={}, status={}, body={}", + portfolio.getUuid(), ex.getStatusCode(), ex.getResponseBodyAsString()); + } else { + log.warn("Portfolio withdrawal create failed: uuid={}", + portfolio.getUuid(), throwable); + } + }); + } + /** * Upserts portfolio trading accounts derived from the provided investment portfolio accounts. * 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 0f98642f5..2276346a3 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 @@ -15,7 +15,10 @@ 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.IntegrationWithdrawalCreate; +import com.backbase.investment.api.service.v1.model.IntegrationWithdrawalList; import com.backbase.investment.api.service.v1.model.PaginatedDepositList; +import com.backbase.investment.api.service.v1.model.PaginatedIntegrationWithdrawalListList; 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; @@ -29,6 +32,7 @@ import com.backbase.stream.investment.InvestmentData; import com.backbase.stream.investment.model.InvestmentPortfolio; import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.List; @@ -888,6 +892,223 @@ void upsertDeposits_apiError_returnsFallbackDeposit() { } } + // ========================================================================= + // upsertWithdrawals + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#upsertWithdrawals(InvestmentPortfolio)}. + * + *

Covers: + *

    + *
  • Withdrawal amount is zero → returns empty Mono, no API call
  • + *
  • No existing withdrawals → creates withdrawal for full configured amount
  • + *
  • Existing withdrawals less than target → creates top-up for remaining amount
  • + *
  • Existing withdrawals equal to or exceed target → returns existing, no new creation
  • + *
  • Null results in paginated response → creates default withdrawal
  • + *
  • {@link WebClientResponseException} from listWithdrawals → returns dummy, no create called
  • + *
  • Generic exception from listWithdrawals → returns dummy, no create called
  • + *
+ */ + @Nested + @DisplayName("upsertWithdrawals") + class UpsertWithdrawalsTests { + + @Test + @DisplayName("withdrawal amount is zero — returns empty Mono without calling any API") + void upsertWithdrawals_zeroWithdrawalAmount_returnsEmpty() { + // Arrange — explicit BigDecimal.ZERO overrides the config default + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-W-ZERO", + OffsetDateTime.now().minusMonths(6)); + InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder() + .portfolio(portfolio) + .withdrawalAmount(BigDecimal.ZERO) + .build(); + + // Act & Assert + StepVerifier.create(service.upsertWithdrawals(investmentPortfolio)) + .verifyComplete(); + + verify(paymentsApi, never()).listWithdrawals(any(), any(), any(), any(), any(), any(), any(), any()); + verify(paymentsApi, never()).createWithdrawal(any()); + } + + @Test + @DisplayName("no existing withdrawals — creates withdrawal for full configured default amount") + void upsertWithdrawals_noExistingWithdrawals_createsFullAmount() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-W-NEW", + OffsetDateTime.now().minusMonths(6)); + InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder() + .portfolio(portfolio) + .build(); + + when(paymentsApi.listWithdrawals(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull())) + .thenReturn(Mono.just(new PaginatedIntegrationWithdrawalListList().results(List.of()))); + + IntegrationWithdrawalCreate created = new IntegrationWithdrawalCreate() + .portfolio(portfolioUuid) + .amount(500d); + when(paymentsApi.createWithdrawal(any())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertWithdrawals(investmentPortfolio)) + .expectNextMatches(w -> portfolioUuid.equals(w.getPortfolio()) && w.getAmount() == 500d) + .verifyComplete(); + + verify(paymentsApi).createWithdrawal(any()); + } + + @Test + @DisplayName("existing withdrawals sum less than target — creates top-up withdrawal for remaining amount") + void upsertWithdrawals_existingWithdrawalsPartial_topsUpRemainingAmount() { + // Arrange — default target is 500; existing sum is 200 → remaining 300 + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-W-TOPUP", + OffsetDateTime.now().minusMonths(6)); + InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder() + .portfolio(portfolio) + .build(); + + IntegrationWithdrawalList existing = Mockito.mock(IntegrationWithdrawalList.class); + when(existing.getAmount()).thenReturn(200d); + + when(paymentsApi.listWithdrawals(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull())) + .thenReturn(Mono.just(new PaginatedIntegrationWithdrawalListList().results(List.of(existing)))); + + IntegrationWithdrawalCreate topUp = new IntegrationWithdrawalCreate() + .portfolio(portfolioUuid) + .amount(300d); + when(paymentsApi.createWithdrawal(any())) + .thenReturn(Mono.just(topUp)); + + // Act & Assert + StepVerifier.create(service.upsertWithdrawals(investmentPortfolio)) + .expectNextMatches(w -> w.getAmount() == 300d) + .verifyComplete(); + + verify(paymentsApi).createWithdrawal(argThat(req -> req.getAmount() == 300d)); + } + + @Test + @DisplayName("existing withdrawals equal to target — returns existing withdrawal without creating new one") + void upsertWithdrawals_existingWithdrawalsFull_returnsExistingWithoutCreating() { + // Arrange — existing sum equals default target of 500 + UUID portfolioUuid = UUID.randomUUID(); + OffsetDateTime completedAt = OffsetDateTime.now().minusDays(10); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-W-FULL", + OffsetDateTime.now().minusMonths(6)); + InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder() + .portfolio(portfolio) + .build(); + + IntegrationWithdrawalList existing = Mockito.mock(IntegrationWithdrawalList.class); + when(existing.getAmount()).thenReturn(500d); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getCompletedAt()).thenReturn(completedAt); + + when(paymentsApi.listWithdrawals(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull())) + .thenReturn(Mono.just(new PaginatedIntegrationWithdrawalListList().results(List.of(existing)))); + + // Act & Assert + StepVerifier.create(service.upsertWithdrawals(investmentPortfolio)) + .expectNextMatches(w -> portfolioUuid.equals(w.getPortfolio()) + && w.getAmount() == 500d + && completedAt.equals(w.getCompletedAt())) + .verifyComplete(); + + verify(paymentsApi, never()).createWithdrawal(any()); + } + + @Test + @DisplayName("null results in paginated response — creates withdrawal for full amount") + void upsertWithdrawals_nullResultsList_createsWithdrawal() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-W-NULL", + OffsetDateTime.now().minusMonths(6)); + InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder() + .portfolio(portfolio) + .build(); + + when(paymentsApi.listWithdrawals(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull())) + .thenReturn(Mono.just(new PaginatedIntegrationWithdrawalListList().results(null))); + + IntegrationWithdrawalCreate created = new IntegrationWithdrawalCreate() + .portfolio(portfolioUuid) + .amount(500d); + when(paymentsApi.createWithdrawal(any())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertWithdrawals(investmentPortfolio)) + .expectNextMatches(w -> portfolioUuid.equals(w.getPortfolio())) + .verifyComplete(); + + verify(paymentsApi).createWithdrawal(any()); + } + + @Test + @DisplayName("listWithdrawals throws WebClientResponseException — returns dummy withdrawal, no create call") + void upsertWithdrawals_listWithdrawalsThrowsWebClientException_returnsDummyWithdrawal() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + OffsetDateTime activated = OffsetDateTime.now().minusMonths(6); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-W-ERR-WCE", activated); + InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder() + .portfolio(portfolio) + .build(); + + when(paymentsApi.listWithdrawals(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull())) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.SERVICE_UNAVAILABLE.value(), "Service Unavailable", + HttpHeaders.EMPTY, "downstream error".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8))); + + // Act & Assert — dummy object: same portfolioUuid, default withdrawal amount, activated + 4 days + StepVerifier.create(service.upsertWithdrawals(investmentPortfolio)) + .expectNextMatches(w -> portfolioUuid.equals(w.getPortfolio()) + && w.getAmount() == 500d + && activated.plusDays(4).equals(w.getCompletedAt())) + .verifyComplete(); + + verify(paymentsApi, never()).createWithdrawal(any()); + } + + @Test + @DisplayName("listWithdrawals throws generic exception — returns dummy withdrawal, no create call") + void upsertWithdrawals_listWithdrawalsThrowsGenericException_returnsDummyWithdrawal() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + OffsetDateTime activated = OffsetDateTime.now().minusMonths(6); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-W-ERR-GEN", activated); + InvestmentPortfolio investmentPortfolio = InvestmentPortfolio.builder() + .portfolio(portfolio) + .build(); + + when(paymentsApi.listWithdrawals(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull())) + .thenReturn(Mono.error(new RuntimeException("unexpected failure"))); + + // Act & Assert + StepVerifier.create(service.upsertWithdrawals(investmentPortfolio)) + .expectNextMatches(w -> portfolioUuid.equals(w.getPortfolio()) + && w.getAmount() == 500d + && activated.plusDays(4).equals(w.getCompletedAt())) + .verifyComplete(); + + verify(paymentsApi, never()).createWithdrawal(any()); + } + } + // ========================================================================= // upsertInvestmentProducts // =========================================================================