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 Implements the same idempotent upsert pattern as {@link #upsertDeposits}:
+ * The withdrawal is created with:
+ * Covers:
+ *
+ *
+ *
+ * @param investmentTask the task containing the portfolios to process
+ * @return Mono emitting the unchanged task after all withdrawals have been processed
+ */
+ private Mono> upsertPortfolios(List
+ *
+ *
+ * @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
+ *
+ *
+ * @param portfolio the portfolio to create the withdrawal for
+ * @param amount the withdrawal amount
+ * @return Mono emitting the created withdrawal
+ */
+ @Nonnull
+ private Mono
+ *
+ */
+ @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
// =========================================================================