From 1156c096133f5d29ce8a239baf015cc97d3154c9 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Fri, 18 Sep 2026 17:05:27 +0530 Subject: [PATCH] Tar-1156 : BSJ | Portfolio trading accounts patch failing due to uniques validation in certain scenarios --- CHANGELOG.md | 4 + .../service/InvestmentPortfolioService.java | 235 ++++++++++++++- .../InvestmentPortfolioServiceTest.java | 284 +++++++++++++++++- 3 files changed, 497 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3219335c..26d8210c3 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.14.0] +### Changed +- Fix for Investment Portfolio trading accounts patch failing due to uniques validation + ## [10.13.0] ### Changed - Add partyGroups to party ingestion 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 8cff0abe6..8ce8a8f45 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 @@ -26,6 +26,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -34,6 +35,7 @@ import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.util.CollectionUtils; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Flux; @@ -453,6 +455,8 @@ private Mono createWithdrawal(PortfolioList portfol * * *
  • Failed accounts are logged and skipped to prevent batch failures
  • + *
  • Accounts sharing a portfolio are processed sequentially (default account first) to avoid + * {@code is_default} races; different portfolios may still be processed concurrently
  • * * * @param investmentPortfolioTradingAccounts the source accounts containing all required field data @@ -469,10 +473,19 @@ public Mono> upsertPortfolioTradingAccounts( } return Flux.fromIterable(investmentPortfolioTradingAccounts) - .flatMap(this::upsertSingleTradingAccount) + .groupBy(account -> account.getPortfolioExternalId() != null + ? account.getPortfolioExternalId() + : "") + .flatMap(portfolioGroup -> portfolioGroup + .sort(defaultAccountFirst()) + .concatMap(this::upsertSingleTradingAccount)) .collectList(); } + private static Comparator defaultAccountFirst() { + return Comparator.comparing(account -> !Boolean.TRUE.equals(account.getIsDefault())); + } + /** * Upserts a single portfolio trading account derived from an investment portfolio account. * @@ -574,7 +587,9 @@ private PortfolioTradingAccountRequest buildTradingAccountRequest( public Mono upsertPortfolioTradingAccount(PortfolioTradingAccountRequest request) { return listExistingPortfolioTradingAccounts(request) - .flatMap(existing -> patchExistingPortfolioTradingAccount(existing, request)) + .flatMap(existing -> requiresRecreate(existing, request) + ? deleteAndRecreateTradingAccount(existing, request) + : patchExistingPortfolioTradingAccount(existing, request)) .switchIfEmpty(Mono.defer(() -> createPortfolioTradingAccount(request))) .doOnSuccess(account -> log.info( "Successfully upserted portfolio trading account: uuid={}, externalAccountId={}", @@ -584,15 +599,26 @@ public Mono upsertPortfolioTradingAccount(PortfolioTrad request.getExternalAccountId(), throwable)); } + /** + * Returns true when the existing record's portfolio or account ID no longer matches the source data. + * + *

    In that case PATCH would attempt to rewrite identity fields and can collide with another row + * in the same portfolio; delete-and-recreate is the correct recovery path. + */ + private boolean requiresRecreate(PortfolioTradingAccount existing, PortfolioTradingAccountRequest request) { + return !Objects.equals(existing.getPortfolio(), request.getPortfolio()) + || !Objects.equals(existing.getAccountId(), request.getAccountId()); + } + /** * Patches an existing portfolio trading account with updated values. * - *

    If the patch operation fails (e.g., due to validation errors or conflicts), - * falls back to returning the existing account to preserve data integrity and prevent batch failures. + *

    On unique-constraint or default-account conflicts, deletes stale/conflicting rows and recreates. + * Other patch failures fall back to the existing account to prevent batch failures. * * @param existing the existing trading account to update * @param request the request containing updated values - * @return Mono emitting the updated trading account, or the existing account if patch fails + * @return Mono emitting the updated trading account, a recreated account, or the existing account */ private Mono patchExistingPortfolioTradingAccount( PortfolioTradingAccount existing, @@ -602,14 +628,76 @@ private Mono patchExistingPortfolioTradingAccount( log.debug("Patching portfolio trading account: uuid={}, externalAccountId={}", uuid, request.getExternalAccountId()); - return portfolioTradingAccountsApi.patchPortfolioTradingAccount(uuid, request) + return clearDefaultIfNeeded(request, existing.getUuid()) + .then(patchTradingAccount(uuid, request)) .doOnSuccess(updated -> log.info( "Successfully patched portfolio trading account: uuid={}", updated.getUuid())) - .doOnError(throwable -> logPortfolioTradingAccountError("PATCH", "uuid", uuid, throwable)) - .onErrorResume(WebClientResponseException.class, ex -> { - log.info("Using existing portfolio trading account due to patch failure: uuid={}", uuid); - return Mono.just(existing); - }); + .onErrorResume(WebClientResponseException.class, ex -> recoverFromPatchConflict(existing, request, ex)); + } + + private Mono patchTradingAccount(String uuid, PortfolioTradingAccountRequest request) { + return portfolioTradingAccountsApi.patchPortfolioTradingAccount(uuid, request) + .doOnError(throwable -> logPortfolioTradingAccountError("PATCH", "uuid", uuid, throwable)); + } + + /** + * Handles PATCH conflicts without deleting unless necessary. Default-account conflicts are resolved by clearing + * other defaults first; unique-constraint conflicts indicate stale rows and trigger delete-and-recreate. + */ + private Mono recoverFromPatchConflict( + PortfolioTradingAccount existing, + PortfolioTradingAccountRequest request, + WebClientResponseException ex) { + + if (!isTradingAccountConflict(ex)) { + log.info("Using existing portfolio trading account due to patch failure: uuid={}", existing.getUuid()); + return Mono.just(existing); + } + + String uuid = existing.getUuid().toString(); + if (isDefaultAccountConflict(ex)) { + log.info( + "Default-account patch conflict for portfolio trading account, clearing other defaults: uuid={}, externalAccountId={}", + uuid, request.getExternalAccountId()); + return clearOtherDefaultTradingAccounts(request.getPortfolio(), existing.getUuid()) + .then(patchTradingAccount(uuid, request)) + .onErrorResume(WebClientResponseException.class, retryEx -> { + if (!isDefaultAccountConflict(retryEx)) { + return Mono.error(retryEx); + } + log.warn( + "Default-account conflict persists after clearing defaults, keeping existing record: uuid={}, externalAccountId={}", + uuid, request.getExternalAccountId()); + return Mono.just(existing); + }); + } + + log.info( + "Unique-constraint patch conflict for portfolio trading account, recreating: uuid={}, externalAccountId={}", + uuid, request.getExternalAccountId()); + return deleteAndRecreateTradingAccount(existing, request); + } + + /** + * Deletes a stale trading account and any rows that would block creation, then creates a fresh record. + */ + private Mono deleteAndRecreateTradingAccount( + PortfolioTradingAccount staleRecord, + PortfolioTradingAccountRequest request) { + + log.info( + "Recreating portfolio trading account: uuid={}, externalAccountId={}, portfolio={}, accountId={}", + staleRecord.getUuid(), request.getExternalAccountId(), request.getPortfolio(), request.getAccountId()); + + return deleteTradingAccount(staleRecord) + .then(deleteConflictingTradingAccounts(request)) + .then(clearDefaultIfNeeded(request)) + .then(Mono.defer(() -> portfolioTradingAccountsApi.createPortfolioTradingAccount(request))) + .doOnSuccess(account -> log.info( + "Recreated portfolio trading account: uuid={}, externalAccountId={}", + account.getUuid(), request.getExternalAccountId())) + .doOnError(throwable -> logPortfolioTradingAccountError( + "RECREATE", "externalAccountId", request.getExternalAccountId(), throwable)); } /** @@ -622,12 +710,126 @@ private Mono patchExistingPortfolioTradingAccount( */ public Mono createPortfolioTradingAccount(PortfolioTradingAccountRequest request) { - return portfolioTradingAccountsApi.createPortfolioTradingAccount(request) + return clearDefaultIfNeeded(request) + .then(Mono.defer(() -> portfolioTradingAccountsApi.createPortfolioTradingAccount(request))) .doOnSuccess(account -> log.info( "Created portfolio trading account: uuid={}, externalAccountId={}", account.getUuid(), request.getExternalAccountId())) .doOnError(throwable -> logPortfolioTradingAccountError( - "CREATE", "externalAccountId", request.getExternalAccountId(), throwable)); + "CREATE", "externalAccountId", request.getExternalAccountId(), throwable)) + .onErrorResume(WebClientResponseException.class, ex -> { + if (!isTradingAccountConflict(ex)) { + return Mono.error(ex); + } + log.info( + "Create conflict detected for portfolio trading account, clearing stale rows: externalAccountId={}", + request.getExternalAccountId()); + return deleteConflictingTradingAccounts(request) + .then(clearDefaultIfNeeded(request)) + .then(Mono.defer(() -> portfolioTradingAccountsApi.createPortfolioTradingAccount(request))); + }); + } + + private Mono clearDefaultIfNeeded(PortfolioTradingAccountRequest request) { + return clearDefaultIfNeeded(request, null); + } + + private Mono clearDefaultIfNeeded(PortfolioTradingAccountRequest request, UUID excludeUuid) { + return Boolean.TRUE.equals(request.getIsDefault()) + ? clearOtherDefaultTradingAccounts(request.getPortfolio(), excludeUuid) + : Mono.empty(); + } + + private Mono deleteTradingAccount(PortfolioTradingAccount account) { + String uuid = account.getUuid().toString(); + return portfolioTradingAccountsApi.deletePortfolioTradingAccount(uuid) + .doOnSuccess(ignored -> log.info( + "Deleted portfolio trading account: uuid={}, externalAccountId={}", + uuid, account.getExternalAccountId())) + .onErrorResume(WebClientResponseException.NotFound.class, ex -> { + log.info("Portfolio trading account already absent during delete: uuid={}", uuid); + return Mono.empty(); + }); + } + + private Mono deleteConflictingTradingAccounts(PortfolioTradingAccountRequest request) { + return Mono.zip( + listTradingAccountsByPortfolioAndAccountId(request.getPortfolio(), request.getAccountId()), + listTradingAccountsByPortfolioAndExternalAccountId( + request.getPortfolio(), request.getExternalAccountId())) + .flatMapMany(tuple -> Flux.concat( + Flux.fromIterable(tuple.getT1()), + Flux.fromIterable(tuple.getT2()))) + .distinct(PortfolioTradingAccount::getUuid) + .flatMap(this::deleteTradingAccount) + .then(); + } + + private Mono> listTradingAccountsByPortfolioAndAccountId( + UUID portfolioUuid, String accountId) { + + return portfolioTradingAccountsApi.listPortfolioTradingAccounts( + null, null, accountId, null, null, null, portfolioUuid.toString()) + .map(this::extractTradingAccountResults) + .defaultIfEmpty(List.of()); + } + + private Mono> listTradingAccountsByPortfolioAndExternalAccountId( + UUID portfolioUuid, String externalAccountId) { + + return portfolioTradingAccountsApi.listPortfolioTradingAccounts( + null, null, null, externalAccountId, null, null, portfolioUuid.toString()) + .map(this::extractTradingAccountResults) + .defaultIfEmpty(List.of()); + } + + private List extractTradingAccountResults( + PaginatedPortfolioTradingAccountList accounts) { + + if (accounts == null || CollectionUtils.isEmpty(accounts.getResults())) { + return List.of(); + } + return accounts.getResults(); + } + + private Mono clearOtherDefaultTradingAccounts(UUID portfolioUuid, UUID excludeUuid) { + return portfolioTradingAccountsApi.listPortfolioTradingAccounts( + null, null, null, null, true, null, portfolioUuid.toString()) + .map(this::extractTradingAccountResults) + .defaultIfEmpty(List.of()) + .flatMapMany(Flux::fromIterable) + .filter(account -> excludeUuid == null || !account.getUuid().equals(excludeUuid)) + .flatMap(account -> portfolioTradingAccountsApi.patchPortfolioTradingAccount( + account.getUuid().toString(), + toPatchRequest(account, false))) + .then(); + } + + /** + * Builds a full PATCH request from an existing account. The investment API requires all fields on PATCH, not + * partial updates. + */ + private PortfolioTradingAccountRequest toPatchRequest(PortfolioTradingAccount account, boolean isDefault) { + return new PortfolioTradingAccountRequest() + .portfolio(account.getPortfolio()) + .accountId(account.getAccountId()) + .externalAccountId(account.getExternalAccountId()) + .isDefault(isDefault) + .isInternal(account.getIsInternal()); + } + + private boolean isTradingAccountConflict(WebClientResponseException ex) { + if (!HttpStatus.BAD_REQUEST.equals(ex.getStatusCode())) { + return false; + } + String body = ex.getResponseBodyAsString(); + return body.contains("must make a unique set") + || body.contains("default trading account already exists"); + } + + private boolean isDefaultAccountConflict(WebClientResponseException ex) { + return HttpStatus.BAD_REQUEST.equals(ex.getStatusCode()) + && ex.getResponseBodyAsString().contains("default trading account already exists"); } /** @@ -648,12 +850,13 @@ private Mono listExistingPortfolioTradingAccounts( PortfolioTradingAccountRequest request) { String externalAccountId = request.getExternalAccountId(); + String portfolioUuid = request.getPortfolio().toString(); return portfolioTradingAccountsApi.listPortfolioTradingAccounts( - 1, null, null, externalAccountId, null, null, null) + 1, null, null, externalAccountId, null, null, portfolioUuid) .doOnSuccess(accounts -> log.debug( - "List portfolio trading accounts query completed: externalAccountId={}, found={} results", - externalAccountId, accounts != null ? accounts.getResults().size() : 0)) + "List portfolio trading accounts query completed: portfolio={}, externalAccountId={}, found={} results", + portfolioUuid, externalAccountId, accounts != null ? accounts.getResults().size() : 0)) .doOnError(throwable -> log.error( "Failed to list existing portfolio trading accounts: externalAccountId={}", externalAccountId, throwable)) 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 4542b47ed..0a2030d13 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 @@ -7,6 +7,7 @@ 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.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -100,7 +101,9 @@ void setUp() { *

      *
    • Existing account found → patch succeeds
    • *
    • No existing account → create new
    • - *
    • Patch fails with {@link WebClientResponseException} → falls back to existing
    • + *
    • Portfolio or account ID changed → delete and recreate
    • + *
    • Patch fails with unique/default conflict → delete and recreate
    • + *
    • Patch fails with non-conflict {@link WebClientResponseException} → falls back to existing
    • *
    • Patch fails with non-WebClient exception → error propagated
    • *
    • Multiple existing accounts → {@link IllegalStateException}
    • *
    @@ -118,6 +121,8 @@ void upsertPortfolioTradingAccount_existingAccount_patchesAndReturns() { PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getAccountId()).thenReturn("ACC-001"); when(existing.getExternalAccountId()).thenReturn("EXT-001"); PortfolioTradingAccount patched = mock(PortfolioTradingAccount.class); @@ -135,8 +140,11 @@ void upsertPortfolioTradingAccount_existingAccount_patchesAndReturns() { .results(List.of(existing)); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-001"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("EXT-001"), isNull(), isNull(), eq(portfolioUuid.toString()))) .thenReturn(Mono.just(accountList)); + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), isNull(), isNull(), eq(true), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( existingUuid.toString(), request)) @@ -171,7 +179,7 @@ void upsertPortfolioTradingAccount_noExistingAccount_createsNew() { .isInternal(false); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-002"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("EXT-002"), isNull(), isNull(), eq(portfolioUuid.toString()))) .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); when(portfolioTradingAccountsApi.createPortfolioTradingAccount(request)) @@ -187,7 +195,240 @@ void upsertPortfolioTradingAccount_noExistingAccount_createsNew() { } @Test - @DisplayName("patch fails with WebClientResponseException — falls back to existing account") + @DisplayName("account identity changed — deletes stale row and recreates") + void upsertPortfolioTradingAccount_identityChanged_deletesAndRecreates() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getAccountId()).thenReturn("ACC-OLD"); + when(existing.getExternalAccountId()).thenReturn("EXT-IDENTITY"); + + PortfolioTradingAccount recreated = mock(PortfolioTradingAccount.class); + UUID recreatedUuid = UUID.randomUUID(); + when(recreated.getUuid()).thenReturn(recreatedUuid); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-NEW") + .externalAccountId("EXT-IDENTITY") + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-IDENTITY"), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of(existing)))); + when(portfolioTradingAccountsApi.deletePortfolioTradingAccount(existingUuid.toString())) + .thenReturn(Mono.empty()); + mockNoTradingAccountConflicts(portfolioUuid, "ACC-NEW", "EXT-IDENTITY", false); + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(request)) + .thenReturn(Mono.just(recreated)); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(acc -> recreatedUuid.equals(acc.getUuid())) + .verifyComplete(); + + verify(portfolioTradingAccountsApi).deletePortfolioTradingAccount(existingUuid.toString()); + verify(portfolioTradingAccountsApi).createPortfolioTradingAccount(request); + verify(portfolioTradingAccountsApi, never()).patchPortfolioTradingAccount(any(), any()); + } + + @Test + @DisplayName("patch fails with unique constraint conflict — deletes stale rows and recreates") + void upsertPortfolioTradingAccount_patchFails_withUniqueConflict_deletesAndRecreates() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + UUID conflictingUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getAccountId()).thenReturn("ACC-STALE"); + when(existing.getExternalAccountId()).thenReturn("EXT-CONFLICT"); + + PortfolioTradingAccount conflicting = mock(PortfolioTradingAccount.class); + when(conflicting.getUuid()).thenReturn(conflictingUuid); + when(conflicting.getExternalAccountId()).thenReturn("EXT-OTHER"); + + PortfolioTradingAccount recreated = mock(PortfolioTradingAccount.class); + UUID recreatedUuid = UUID.randomUUID(); + when(recreated.getUuid()).thenReturn(recreatedUuid); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-TARGET") + .externalAccountId("EXT-CONFLICT") + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-CONFLICT"), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of(existing)))); + + String conflictBody = "{\"errors\":{\"non_field_errors\":" + + "[\"The fields portfolio, account_id must make a unique set.\"]}}"; + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount(existingUuid.toString(), request)) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.BAD_REQUEST.value(), "Bad Request", + HttpHeaders.EMPTY, conflictBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))); + + when(portfolioTradingAccountsApi.deletePortfolioTradingAccount(existingUuid.toString())) + .thenReturn(Mono.empty()); + when(portfolioTradingAccountsApi.deletePortfolioTradingAccount(conflictingUuid.toString())) + .thenReturn(Mono.empty()); + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), eq("ACC-TARGET"), isNull(), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of(conflicting)))); + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), isNull(), eq("EXT-CONFLICT"), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(request)) + .thenReturn(Mono.just(recreated)); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(acc -> recreatedUuid.equals(acc.getUuid())) + .verifyComplete(); + + verify(portfolioTradingAccountsApi).deletePortfolioTradingAccount(existingUuid.toString()); + verify(portfolioTradingAccountsApi).deletePortfolioTradingAccount(conflictingUuid.toString()); + verify(portfolioTradingAccountsApi).createPortfolioTradingAccount(request); + } + + @Test + @DisplayName("patch with isDefault=true — clears other defaults before patching") + void upsertPortfolioTradingAccount_defaultAccount_clearsOtherDefaultsBeforePatch() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + UUID otherDefaultUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getAccountId()).thenReturn("ACC-DEF"); + when(existing.getExternalAccountId()).thenReturn("EXT-DEF"); + + PortfolioTradingAccount otherDefault = mock(PortfolioTradingAccount.class); + when(otherDefault.getUuid()).thenReturn(otherDefaultUuid); + when(otherDefault.getPortfolio()).thenReturn(portfolioUuid); + when(otherDefault.getAccountId()).thenReturn("ACC-OTHER-DEF"); + when(otherDefault.getExternalAccountId()).thenReturn("EXT-OTHER-DEF"); + when(otherDefault.getIsInternal()).thenReturn(true); + + PortfolioTradingAccountRequest clearDefaultRequest = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-OTHER-DEF") + .externalAccountId("EXT-OTHER-DEF") + .isDefault(false) + .isInternal(true); + + PortfolioTradingAccount patched = mock(PortfolioTradingAccount.class); + when(patched.getUuid()).thenReturn(existingUuid); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-DEF") + .externalAccountId("EXT-DEF") + .isDefault(true) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-DEF"), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of(existing)))); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), isNull(), isNull(), eq(true), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of(otherDefault)))); + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + otherDefaultUuid.toString(), clearDefaultRequest)) + .thenReturn(Mono.just(otherDefault)); + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount(existingUuid.toString(), request)) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(acc -> existingUuid.equals(acc.getUuid())) + .verifyComplete(); + + verify(portfolioTradingAccountsApi, never()).deletePortfolioTradingAccount(any()); + verify(portfolioTradingAccountsApi).patchPortfolioTradingAccount( + otherDefaultUuid.toString(), clearDefaultRequest); + verify(portfolioTradingAccountsApi).patchPortfolioTradingAccount(existingUuid.toString(), request); + } + + @Test + @DisplayName("patch fails with default conflict — clears other defaults and retries patch") + void upsertPortfolioTradingAccount_patchFails_withDefaultConflict_clearsDefaultsAndRetriesPatch() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + UUID otherDefaultUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getAccountId()).thenReturn("ACC-DEF"); + when(existing.getExternalAccountId()).thenReturn("EXT-DEF"); + + PortfolioTradingAccount otherDefault = mock(PortfolioTradingAccount.class); + when(otherDefault.getUuid()).thenReturn(otherDefaultUuid); + when(otherDefault.getPortfolio()).thenReturn(portfolioUuid); + when(otherDefault.getAccountId()).thenReturn("ACC-OTHER-DEF"); + when(otherDefault.getExternalAccountId()).thenReturn("EXT-OTHER-DEF"); + when(otherDefault.getIsInternal()).thenReturn(true); + + PortfolioTradingAccountRequest clearDefaultRequest = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-OTHER-DEF") + .externalAccountId("EXT-OTHER-DEF") + .isDefault(false) + .isInternal(true); + + PortfolioTradingAccount patched = mock(PortfolioTradingAccount.class); + when(patched.getUuid()).thenReturn(existingUuid); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-DEF") + .externalAccountId("EXT-DEF") + .isDefault(true) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-DEF"), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of(existing)))); + + String defaultConflictBody = "{\"errors\":{\"is_default\":" + + "[\"A default trading account already exists for this portfolio.\"]}}"; + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), isNull(), isNull(), eq(true), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of(otherDefault)))); + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + otherDefaultUuid.toString(), clearDefaultRequest)) + .thenReturn(Mono.just(otherDefault)); + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount(existingUuid.toString(), request)) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.BAD_REQUEST.value(), "Bad Request", + HttpHeaders.EMPTY, defaultConflictBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(acc -> existingUuid.equals(acc.getUuid())) + .verifyComplete(); + + verify(portfolioTradingAccountsApi, never()).deletePortfolioTradingAccount(any()); + verify(portfolioTradingAccountsApi, times(2)).patchPortfolioTradingAccount(existingUuid.toString(), request); + } + + @Test + @DisplayName("patch fails with non-conflict WebClientResponseException — falls back to existing account") void upsertPortfolioTradingAccount_patchFails_withWebClientException_fallsBackToExisting() { // Arrange UUID existingUuid = UUID.randomUUID(); @@ -195,6 +436,8 @@ void upsertPortfolioTradingAccount_patchFails_withWebClientException_fallsBackTo PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getAccountId()).thenReturn("ACC-003"); when(existing.getExternalAccountId()).thenReturn("EXT-003"); PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() @@ -208,7 +451,7 @@ void upsertPortfolioTradingAccount_patchFails_withWebClientException_fallsBackTo .results(List.of(existing)); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-003"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("EXT-003"), isNull(), isNull(), eq(portfolioUuid.toString()))) .thenReturn(Mono.just(accountList)); when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( @@ -237,6 +480,8 @@ void upsertPortfolioTradingAccount_patchFails_withNonWebClientException_propagat PortfolioTradingAccount existing = mock(PortfolioTradingAccount.class); when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getPortfolio()).thenReturn(portfolioUuid); + when(existing.getAccountId()).thenReturn("ACC-004"); when(existing.getExternalAccountId()).thenReturn("EXT-004"); PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() @@ -250,7 +495,7 @@ void upsertPortfolioTradingAccount_patchFails_withNonWebClientException_propagat .results(List.of(existing)); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-004"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("EXT-004"), isNull(), isNull(), eq(portfolioUuid.toString()))) .thenReturn(Mono.just(accountList)); when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( @@ -283,7 +528,7 @@ void upsertPortfolioTradingAccount_multipleExistingAccounts_returnsError() { .isInternal(false); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-005"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("EXT-005"), isNull(), isNull(), eq(portfolioUuid.toString()))) .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList() .results(List.of(acc1, acc2)))); @@ -372,7 +617,7 @@ void upsertPortfolioTradingAccounts_singleFailure_doesNotStopBatch() { // Account 1: list returns empty → create fails when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("ACC-FAIL-001"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("ACC-FAIL-001"), isNull(), isNull(), eq(portfolioUuid1.toString()))) .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); when(portfolioTradingAccountsApi.createPortfolioTradingAccount( argThat(r -> r != null && "ACC-FAIL-001".equals(r.getExternalAccountId())))) @@ -383,7 +628,7 @@ void upsertPortfolioTradingAccounts_singleFailure_doesNotStopBatch() { PortfolioTradingAccount created = mock(PortfolioTradingAccount.class); when(created.getUuid()).thenReturn(createdUuid); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("ACC-OK-002"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("ACC-OK-002"), isNull(), isNull(), eq(portfolioUuid2.toString()))) .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); when(portfolioTradingAccountsApi.createPortfolioTradingAccount( argThat(r -> r != null && "ACC-OK-002".equals(r.getExternalAccountId())))) @@ -423,7 +668,7 @@ void upsertPortfolioTradingAccounts_allFail_returnsEmptyList() { mockPortfolioFound(externalId, portfolioUuid); when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("ACC-ALL-FAIL"), isNull(), isNull(), isNull())) + eq(1), isNull(), isNull(), eq("ACC-ALL-FAIL"), isNull(), isNull(), eq(portfolioUuid.toString()))) .thenReturn(Mono.error(new RuntimeException("API failure"))); List input = List.of( @@ -478,6 +723,7 @@ void createPortfolioTradingAccount_success_returnsCreatedAccount() { .isDefault(true) .isInternal(false); + mockNoTradingAccountConflicts(portfolioUuid, "ACC-NEW", "EXT-NEW", true); when(portfolioTradingAccountsApi.createPortfolioTradingAccount(request)) .thenReturn(Mono.just(created)); @@ -1692,6 +1938,24 @@ private InvestmentArrangement buildArrangementWithProductType(String externalId, } + /** + * Stubs list calls used during delete-and-recreate conflict resolution to return no matching rows. + */ + private void mockNoTradingAccountConflicts(UUID portfolioUuid, String accountId, String externalAccountId, + boolean isDefault) { + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), eq(accountId), isNull(), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), isNull(), eq(externalAccountId), isNull(), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + if (isDefault) { + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + isNull(), isNull(), isNull(), isNull(), eq(true), isNull(), eq(portfolioUuid.toString()))) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + } + } + /** * Stubs {@link PortfolioApi#listPortfolios} to return an existing portfolio with the given UUID. * Used in trading account tests that need a resolved portfolio UUID.