This is an automated email from the ASF dual-hosted git repository.

adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git


The following commit(s) were added to refs/heads/develop by this push:
     new 020754bad4 FINERACT-2421: Retry external asset owner creation in case 
of unique constraint violation
020754bad4 is described below

commit 020754bad4820dbfc8a6f3d3af419541ac42e815
Author: Oleksii Novikov <[email protected]>
AuthorDate: Tue Feb 10 14:46:13 2026 +0200

    FINERACT-2421: Retry external asset owner creation in case of unique 
constraint violation
---
 .../domain/ExternalAssetOwnerRepository.java       |   4 +
 .../investor/service/ExternalAssetOwnerHelper.java |  46 +++++++++
 .../ExternalAssetOwnersWriteServiceImpl.java       |  91 ++++++++++++------
 .../ExternalAssetOwnersWriteServiceTest.java       | 105 +++++++++++++++++++--
 .../InitiateExternalAssetOwnerTransferTest.java    |  77 +++++++++++++++
 5 files changed, 285 insertions(+), 38 deletions(-)

diff --git 
a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerRepository.java
 
b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerRepository.java
index 90e5542478..dacf5455fd 100644
--- 
a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerRepository.java
+++ 
b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerRepository.java
@@ -22,10 +22,14 @@ import java.util.Optional;
 import org.apache.fineract.infrastructure.core.domain.ExternalId;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Query;
 
 public interface ExternalAssetOwnerRepository
         extends JpaRepository<ExternalAssetOwner, Long>, 
JpaSpecificationExecutor<ExternalAssetOwner> {
 
     Optional<ExternalAssetOwner> findByExternalId(ExternalId externalId);
 
+    @Query("SELECT e.id FROM ExternalAssetOwner e WHERE e.externalId = 
:externalId")
+    Optional<Long> findIdByExternalId(ExternalId externalId);
+
 }
diff --git 
a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerHelper.java
 
b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerHelper.java
new file mode 100644
index 0000000000..7558c67f12
--- /dev/null
+++ 
b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerHelper.java
@@ -0,0 +1,46 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.investor.service;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.domain.ExternalId;
+import org.apache.fineract.investor.domain.ExternalAssetOwner;
+import org.apache.fineract.investor.domain.ExternalAssetOwnerRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ExternalAssetOwnerHelper {
+
+    private final ExternalAssetOwnerRepository repository;
+
+    // REQUIRES_NEW isolates the INSERT into a separate transaction and 
persistence context,
+    // so a constraint violation does not corrupt the caller's session or mark 
the
+    // outer transaction as rollback-only, allowing a safe retry.
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public Long findOrCreateId(final ExternalId externalId) {
+        return repository.findIdByExternalId(externalId).orElseGet(() -> {
+            final ExternalAssetOwner owner = new ExternalAssetOwner();
+            owner.setExternalId(externalId);
+            return repository.saveAndFlush(owner).getId();
+        });
+    }
+}
diff --git 
a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java
 
b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java
index 8fd3873bd9..13ee21e47d 100644
--- 
a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java
+++ 
b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceImpl.java
@@ -25,6 +25,7 @@ import static 
org.apache.fineract.investor.data.ExternalTransferStatus.PENDING_I
 import com.google.gson.JsonElement;
 import com.google.gson.reflect.TypeToken;
 import java.lang.reflect.Type;
+import java.sql.SQLException;
 import java.time.LocalDate;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -65,6 +66,9 @@ import 
org.apache.fineract.investor.serialization.ExternalAssetOwnerValidator;
 import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
 import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
 import 
org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
+import org.springframework.dao.DataAccessException;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.orm.jpa.JpaSystemException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -77,6 +81,8 @@ public class ExternalAssetOwnersWriteServiceImpl implements 
ExternalAssetOwnersW
             ExternalTransferStatus.ACTIVE);
     private static final List<ExternalTransferStatus> 
BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT = List
             .of(ExternalTransferStatus.ACTIVE_INTERMEDIATE, 
ExternalTransferStatus.ACTIVE);
+    private static final String SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION = 
"23";
+
     private final ExternalAssetOwnerTransferRepository 
externalAssetOwnerTransferRepository;
     private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
     private final FromJsonHelper fromApiJsonHelper;
@@ -85,6 +91,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements 
ExternalAssetOwnersW
     private final ConfigurationDomainService configurationDomainService;
     private final ExternalAssetOwnersReadService 
externalAssetOwnersReadService;
     private final ExternalAssetOwnerValidator externalAssetOwnerValidator;
+    private final ExternalAssetOwnerHelper externalAssetOwnerHelper;
 
     @Override
     @Transactional
@@ -171,15 +178,16 @@ public class ExternalAssetOwnersWriteServiceImpl 
implements ExternalAssetOwnersW
         if (effectiveTransfers.size() == 2) {
             throw new ExternalAssetOwnerInitiateTransferException("This loan 
cannot be sold, there is already an in progress transfer");
         } else if (effectiveTransfers.size() == 1) {
-            if (PENDING.equals(effectiveTransfers.get(0).getStatus())) {
+            if (PENDING.equals(effectiveTransfers.getFirst().getStatus())) {
                 throw new ExternalAssetOwnerInitiateTransferException(
                         "External asset owner transfer is already in PENDING 
state for this loan");
-            } else if 
(ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.get(0).getStatus())) {
+            } else if 
(ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus()))
 {
                 throw new ExternalAssetOwnerInitiateTransferException(
                         "This loan cannot be sold, because it is owned by an 
external asset owner");
             } else {
-                throw new 
ExternalAssetOwnerInitiateTransferException(String.format(
-                        "This loan cannot be sold, because it is incorrect 
state! (transferId = %s)", effectiveTransfers.get(0).getId()));
+                throw new ExternalAssetOwnerInitiateTransferException(
+                        String.format("This loan cannot be sold, because it is 
incorrect state! (transferId = %s)",
+                                effectiveTransfers.getFirst().getId()));
             }
         }
     }
@@ -188,7 +196,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements 
ExternalAssetOwnersW
         if (effectiveTransfers.size() > 1) {
             throw new ExternalAssetOwnerInitiateTransferException("This loan 
cannot be sold, there is already an in progress transfer");
         } else if (effectiveTransfers.size() == 1) {
-            if 
(!ACTIVE_INTERMEDIATE.equals(effectiveTransfers.get(0).getStatus())) {
+            if 
(!ACTIVE_INTERMEDIATE.equals(effectiveTransfers.getFirst().getStatus())) {
                 throw new ExternalAssetOwnerInitiateTransferException(
                         "This loan cannot be sold, because it is not in 
ACTIVE-INTERMEDIATE state.");
             }
@@ -204,15 +212,16 @@ public class ExternalAssetOwnersWriteServiceImpl 
implements ExternalAssetOwnersW
         if (effectiveTransfers.size() > 1) {
             throw new ExternalAssetOwnerInitiateTransferException("This loan 
cannot be sold, there is already an in progress transfer");
         } else if (effectiveTransfers.size() == 1) {
-            if 
(PENDING_INTERMEDIATE.equals(effectiveTransfers.get(0).getStatus())) {
+            if 
(PENDING_INTERMEDIATE.equals(effectiveTransfers.getFirst().getStatus())) {
                 throw new ExternalAssetOwnerInitiateTransferException(
                         "External asset owner transfer is already in 
PENDING_INTERMEDIATE state for this loan");
-            } else if 
(ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.get(0).getStatus())) {
+            } else if 
(ExternalTransferStatus.ACTIVE.equals(effectiveTransfers.getFirst().getStatus()))
 {
                 throw new ExternalAssetOwnerInitiateTransferException(
                         "This loan cannot be sold, because it is owned by an 
external asset owner");
             } else {
-                throw new 
ExternalAssetOwnerInitiateTransferException(String.format(
-                        "This loan cannot be sold, because it is incorrect 
state! (transferId = %s)", effectiveTransfers.get(0).getId()));
+                throw new ExternalAssetOwnerInitiateTransferException(
+                        String.format("This loan cannot be sold, because it is 
incorrect state! (transferId = %s)",
+                                effectiveTransfers.getFirst().getId()));
             }
         }
     }
@@ -232,17 +241,17 @@ public class ExternalAssetOwnersWriteServiceImpl 
implements ExternalAssetOwnersW
         } else if (effectiveTransfers.size() == 2) {
             throw new ExternalAssetOwnerInitiateTransferException(
                     "This loan cannot be bought back, external asset owner 
buyback transfer is already in progress");
-        } else if 
(!BUYBACK_READY_STATUSES.contains(effectiveTransfers.get(0).getStatus())) {
+        } else if 
(!BUYBACK_READY_STATUSES.contains(effectiveTransfers.getFirst().getStatus())) {
             throw new ExternalAssetOwnerInitiateTransferException(
                     String.format("This loan cannot be bought back, effective 
transfer is not in right state: %s",
-                            effectiveTransfers.get(0).getStatus()));
-        } else if (DateUtils.isBefore(settlementDate, 
effectiveTransfers.get(0).getSettlementDate())) {
+                            effectiveTransfers.getFirst().getStatus()));
+        } else if (DateUtils.isBefore(settlementDate, 
effectiveTransfers.getFirst().getSettlementDate())) {
             throw new ExternalAssetOwnerInitiateTransferException(
                     String.format("This loan cannot be bought back, settlement 
date is earlier than effective transfer settlement date: %s",
-                            effectiveTransfers.get(0).getSettlementDate()));
+                            
effectiveTransfers.getFirst().getSettlementDate()));
         }
 
-        return effectiveTransfers.get(0);
+        return effectiveTransfers.getFirst();
     }
 
     private ExternalAssetOwnerTransfer 
fetchAndValidateEffectiveTransferForBuybackWithDelayedSettlement(
@@ -265,17 +274,17 @@ public class ExternalAssetOwnersWriteServiceImpl 
implements ExternalAssetOwnersW
                 || Set.of(ExternalTransferStatus.ACTIVE, 
ExternalTransferStatus.BUYBACK).equals(effectiveTransferStatuses)) {
             throw new ExternalAssetOwnerInitiateTransferException(
                     "This loan cannot be bought back, external asset owner 
buyback transfer is already in progress");
-        } else if 
(!BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT.contains(effectiveTransfers.get(0).getStatus()))
 {
+        } else if 
(!BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT.contains(effectiveTransfers.getFirst().getStatus()))
 {
             throw new ExternalAssetOwnerInitiateTransferException(
                     String.format("This loan cannot be bought back, effective 
transfer is not in right state: %s",
-                            effectiveTransfers.get(0).getStatus()));
-        } else if (DateUtils.isBefore(settlementDate, 
effectiveTransfers.get(0).getSettlementDate())) {
+                            effectiveTransfers.getFirst().getStatus()));
+        } else if (DateUtils.isBefore(settlementDate, 
effectiveTransfers.getFirst().getSettlementDate())) {
             throw new ExternalAssetOwnerInitiateTransferException(
                     String.format("This loan cannot be bought back, settlement 
date is earlier than effective transfer settlement date: %s",
-                            effectiveTransfers.get(0).getSettlementDate()));
+                            
effectiveTransfers.getFirst().getSettlementDate()));
         }
 
-        return effectiveTransfers.get(0);
+        return effectiveTransfers.getFirst();
     }
 
     private ExternalAssetOwnerTransfer 
fetchAndValidateEffectiveTransferForCancel(final Long transferId) {
@@ -287,10 +296,9 @@ public class ExternalAssetOwnersWriteServiceImpl 
implements ExternalAssetOwnersW
                 
.findEffectiveTransfersOrderByIdDesc(selectedTransfer.getLoanId(), 
DateUtils.getBusinessLocalDate());
         if (effective.isEmpty()) {
             throw new ExternalAssetOwnerInitiateTransferException(
-                    String.format("This loan cannot be cancelled, there is no 
effective transfer for this loan"));
-        } else if (!Objects.equals(effective.get(0).getId(), 
selectedTransfer.getId())) {
-            throw new ExternalAssetOwnerInitiateTransferException(
-                    String.format("This loan cannot be cancelled, selected 
transfer is not the latest"));
+                    "This loan cannot be cancelled, there is no effective 
transfer for this loan");
+        } else if (!Objects.equals(effective.getFirst().getId(), 
selectedTransfer.getId())) {
+            throw new ExternalAssetOwnerInitiateTransferException("This loan 
cannot be cancelled, selected transfer is not the latest");
         } else if (selectedTransfer.getStatus() != PENDING && 
selectedTransfer.getStatus() != ExternalTransferStatus.BUYBACK) {
             throw new ExternalAssetOwnerInitiateTransferException(
                     "This loan cannot be cancelled, the selected transfer 
status is not pending or buyback");
@@ -318,8 +326,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements 
ExternalAssetOwnersW
 
     private ExternalTransferStatus 
determineStatusAfterBuyback(ExternalAssetOwnerTransfer effectiveTransfer) {
         return switch (effectiveTransfer.getStatus()) {
-            case PENDING -> ExternalTransferStatus.BUYBACK;
-            case ACTIVE -> ExternalTransferStatus.BUYBACK;
+            case PENDING, ACTIVE -> ExternalTransferStatus.BUYBACK;
             case ACTIVE_INTERMEDIATE -> 
ExternalTransferStatus.BUYBACK_INTERMEDIATE;
             default -> throw new 
ExternalAssetOwnerInitiateTransferException(String.format(
                     "This loan cannot be bought back, effective transfer is 
not in right state: %s", effectiveTransfer.getStatus()));
@@ -582,11 +589,33 @@ public class ExternalAssetOwnersWriteServiceImpl 
implements ExternalAssetOwnersW
         return 
fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.PURCHASE_PRICE_RATIO,
 json);
     }
 
-    private ExternalAssetOwner getOwner(JsonElement json) {
-        String ownerExternalId = 
fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID,
 json);
-        Optional<ExternalAssetOwner> byExternalId = 
externalAssetOwnerRepository
-                .findByExternalId(ExternalIdFactory.produce(ownerExternalId));
-        return byExternalId.orElseGet(() -> 
createAndGetAssetOwner(ownerExternalId));
+    private ExternalAssetOwner getOwner(final JsonElement json) {
+        final String ownerExternalId = 
fromApiJsonHelper.extractStringNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID,
 json);
+        final ExternalId externalId = 
ExternalIdFactory.produce(ownerExternalId);
+        return 
externalAssetOwnerRepository.findByExternalId(externalId).orElseGet(() -> {
+            final Long ownerId = findOrCreateOwnerId(externalId);
+            // getReferenceById returns a lazy proxy without hitting the DB. 
findById would fail
+            // here because the outer transaction's persistence context does 
not contain the entity
+            // committed by the inner REQUIRES_NEW transaction.
+            return externalAssetOwnerRepository.getReferenceById(ownerId);
+        });
+    }
+
+    private Long findOrCreateOwnerId(final ExternalId externalId) {
+        try {
+            return externalAssetOwnerHelper.findOrCreateId(externalId);
+        } catch (JpaSystemException | DataIntegrityViolationException e) {
+            if (!isConstraintViolation(e)) {
+                throw e;
+            }
+            // Another thread created the owner concurrently - retry
+            return externalAssetOwnerHelper.findOrCreateId(externalId);
+        }
+    }
+
+    private boolean isConstraintViolation(final DataAccessException e) {
+        return e.getMostSpecificCause() instanceof SQLException sqlEx && 
sqlEx.getSQLState() != null
+                && 
sqlEx.getSQLState().startsWith(SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION);
     }
 
     private ExternalAssetOwner createAndGetAssetOwner(String externalId) {
@@ -611,7 +640,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements 
ExternalAssetOwnersW
         String ownerExternalId = 
command.stringValueOfParameterNamed(ExternalTransferRequestParameters.OWNER_EXTERNAL_ID);
         Optional<ExternalAssetOwner> optExternalId = 
externalAssetOwnerRepository
                 .findByExternalId(ExternalIdFactory.produce(ownerExternalId));
-        if (!optExternalId.isEmpty()) {
+        if (optExternalId.isPresent()) {
             throw new ExternalAssetOwnerDuplicateException(ownerExternalId);
         }
 
diff --git 
a/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceTest.java
 
b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceTest.java
index 1276875d54..7ab96df1cd 100644
--- 
a/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceTest.java
+++ 
b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnersWriteServiceTest.java
@@ -38,6 +38,7 @@ import com.google.gson.JsonElement;
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.sql.SQLException;
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.ArrayList;
@@ -79,7 +80,9 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.data.jpa.domain.Specification;
+import org.springframework.orm.jpa.JpaSystemException;
 
 @ExtendWith(MockitoExtension.class)
 public class ExternalAssetOwnersWriteServiceTest {
@@ -346,7 +349,7 @@ public class ExternalAssetOwnersWriteServiceTest {
                 
testContext.fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE,
 jsonCommandElement))
                 .thenReturn(LocalDate.EPOCH);
         
lenient().when(testContext.fromApiJsonHelper.extractLocalDateNamed(ExternalTransferRequestParameters.SETTLEMENT_DATE,
-                jsonCommandElement, testContext.DATE_FORMAT, 
Locale.GERMANY)).thenReturn(LocalDate.EPOCH);
+                jsonCommandElement, TestContext.DATE_FORMAT, 
Locale.GERMANY)).thenReturn(LocalDate.EPOCH);
 
         // given
         final JsonCommand command = createJsonCommand(testContext.jsonCommand, 
testContext.loanId);
@@ -364,7 +367,7 @@ public class ExternalAssetOwnersWriteServiceTest {
         verify(testContext.externalAssetOwnerRepository, 
times(0)).saveAndFlush(any(ExternalAssetOwner.class));
         
verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId);
         
verify(testContext.delayedSettlementAttributeService).isEnabled(testContext.loanProductId);
-        Assertions.assertEquals(thrownException.getMessage(), "Settlement date 
cannot be in the past");
+        Assertions.assertEquals("Settlement date cannot be in the past", 
thrownException.getMessage());
     }
 
     private static Stream<Arguments> effectiveTransferDataProvider() {
@@ -440,14 +443,17 @@ public class ExternalAssetOwnersWriteServiceTest {
         
when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId))
                 
.thenReturn(Optional.of(testContext.loanDataForExternalTransfer));
         
when(testContext.externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))).thenReturn(Optional.empty());
+        
when(testContext.externalAssetOwnerHelper.findOrCreateId(any(ExternalId.class))).thenReturn(42L);
+        
when(testContext.externalAssetOwnerRepository.getReferenceById(42L)).thenReturn(testContext.externalAssetOwner);
 
         // when
         CommandProcessingResult result = 
testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command);
 
         // then
         
verify(testContext.externalAssetOwnerRepository).findByExternalId(any(ExternalId.class));
+        
verify(testContext.externalAssetOwnerHelper).findOrCreateId(any(ExternalId.class));
+        verify(testContext.externalAssetOwnerRepository).getReferenceById(42L);
         
verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(externalAssetOwnerTransferArgumentCaptor.capture());
-        
verify(testContext.externalAssetOwnerRepository).saveAndFlush(any(ExternalAssetOwner.class));
         
verify(testContext.externalAssetOwnerTransferRepository).findEffectiveTransfersOrderByIdDesc(eq(testContext.loanId),
                 any(LocalDate.class));
         
verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId);
@@ -462,6 +468,89 @@ public class ExternalAssetOwnersWriteServiceTest {
         assertEquals(savedTransfer.getExternalLoanId(), 
result.getSubResourceExternalId());
     }
 
+    @Test
+    public void 
verifyWhenOwnerCreationHitsConstraintViolationWithJpaSystemExceptionThenRetrySucceeds()
 {
+        final TestContext testContext = new TestContext();
+        final ArgumentCaptor<ExternalAssetOwnerTransfer> transferCaptor = 
ArgumentCaptor.forClass(ExternalAssetOwnerTransfer.class);
+
+        // given
+        final JsonCommand command = createJsonCommand(testContext.jsonCommand, 
testContext.loanId);
+
+        final SQLException sqlException = new SQLException("Duplicate entry", 
"23505");
+        final JpaSystemException jpaException = new JpaSystemException(new 
RuntimeException(sqlException));
+
+        
when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(false);
+        
when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId))
+                
.thenReturn(Optional.of(testContext.loanDataForExternalTransfer));
+        
when(testContext.externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))).thenReturn(Optional.empty());
+        
when(testContext.externalAssetOwnerHelper.findOrCreateId(any(ExternalId.class))).thenThrow(jpaException).thenReturn(99L);
+        
when(testContext.externalAssetOwnerRepository.getReferenceById(99L)).thenReturn(testContext.externalAssetOwner);
+
+        // when
+        CommandProcessingResult result = 
testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command);
+
+        // then
+        verify(testContext.externalAssetOwnerHelper, 
times(2)).findOrCreateId(any(ExternalId.class));
+        verify(testContext.externalAssetOwnerRepository).getReferenceById(99L);
+        
verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(transferCaptor.capture());
+
+        ExternalAssetOwnerTransfer savedTransfer = transferCaptor.getValue();
+        assertAssertOwnerTransferValues(testContext, savedTransfer, 
ExternalTransferStatus.PENDING);
+        assertEquals(savedTransfer.getId(), result.getResourceId());
+    }
+
+    @Test
+    public void 
verifyWhenOwnerCreationHitsConstraintViolationWithDataIntegrityViolationExceptionThenRetrySucceeds()
 {
+        final TestContext testContext = new TestContext();
+        final ArgumentCaptor<ExternalAssetOwnerTransfer> transferCaptor = 
ArgumentCaptor.forClass(ExternalAssetOwnerTransfer.class);
+
+        // given
+        final JsonCommand command = createJsonCommand(testContext.jsonCommand, 
testContext.loanId);
+
+        final SQLException sqlException = new SQLException("Duplicate entry", 
"23000");
+        final DataIntegrityViolationException diveException = new 
DataIntegrityViolationException("Duplicate", sqlException);
+
+        
when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(false);
+        
when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId))
+                
.thenReturn(Optional.of(testContext.loanDataForExternalTransfer));
+        
when(testContext.externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))).thenReturn(Optional.empty());
+        
when(testContext.externalAssetOwnerHelper.findOrCreateId(any(ExternalId.class))).thenThrow(diveException).thenReturn(99L);
+        
when(testContext.externalAssetOwnerRepository.getReferenceById(99L)).thenReturn(testContext.externalAssetOwner);
+
+        // when
+        CommandProcessingResult result = 
testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command);
+
+        // then
+        verify(testContext.externalAssetOwnerHelper, 
times(2)).findOrCreateId(any(ExternalId.class));
+        verify(testContext.externalAssetOwnerRepository).getReferenceById(99L);
+        
verify(testContext.externalAssetOwnerTransferRepository).saveAndFlush(transferCaptor.capture());
+
+        ExternalAssetOwnerTransfer savedTransfer = transferCaptor.getValue();
+        assertAssertOwnerTransferValues(testContext, savedTransfer, 
ExternalTransferStatus.PENDING);
+        assertEquals(savedTransfer.getId(), result.getResourceId());
+    }
+
+    @Test
+    public void 
verifyWhenOwnerCreationThrowsNonConstraintJpaSystemExceptionThenExceptionPropagates()
 {
+        final TestContext testContext = new TestContext();
+
+        // given
+        final JsonCommand command = createJsonCommand(testContext.jsonCommand, 
testContext.loanId);
+
+        final JpaSystemException jpaException = new JpaSystemException(new 
RuntimeException("Connection lost"));
+
+        
when(testContext.delayedSettlementAttributeService.isEnabled(testContext.loanProductId)).thenReturn(false);
+        
when(testContext.loanRepository.findLoanDataForExternalTransferByLoanId(testContext.loanId))
+                
.thenReturn(Optional.of(testContext.loanDataForExternalTransfer));
+        
when(testContext.externalAssetOwnerRepository.findByExternalId(any(ExternalId.class))).thenReturn(Optional.empty());
+        
when(testContext.externalAssetOwnerHelper.findOrCreateId(any(ExternalId.class))).thenThrow(jpaException);
+
+        // when & then
+        assertThrows(JpaSystemException.class, () -> 
testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command));
+
+        verify(testContext.externalAssetOwnerHelper, 
times(1)).findOrCreateId(any(ExternalId.class));
+    }
+
     @Test
     public void verifyWhenLoanIsNotFoundThenExceptionIsThrown() {
         final TestContext testContext = new TestContext();
@@ -574,7 +663,7 @@ public class ExternalAssetOwnersWriteServiceTest {
         ExternalAssetOwnerInitiateTransferException exception = 
assertThrows(ExternalAssetOwnerInitiateTransferException.class,
                 () -> 
testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command));
 
-        assertEquals(exception.getMessage(), "This loan cannot be sold, there 
is already an in progress transfer");
+        assertEquals("This loan cannot be sold, there is already an in 
progress transfer", exception.getMessage());
 
         // then
         verify(testContext.fromApiJsonHelper, times(2)).parse(command.json());
@@ -607,7 +696,7 @@ public class ExternalAssetOwnersWriteServiceTest {
         ExternalAssetOwnerInitiateTransferException exception = 
assertThrows(ExternalAssetOwnerInitiateTransferException.class,
                 () -> 
testContext.externalAssetOwnersWriteServiceImpl.saleLoanByLoanId(command));
 
-        assertEquals(exception.getMessage(), "This loan cannot be sold, no 
effective transfer found.");
+        assertEquals("This loan cannot be sold, no effective transfer found.", 
exception.getMessage());
         // then
         verify(testContext.fromApiJsonHelper, times(2)).parse(command.json());
         
verify(testContext.loanRepository).findLoanDataForExternalTransferByLoanId(testContext.loanId);
@@ -699,7 +788,7 @@ public class ExternalAssetOwnersWriteServiceTest {
         ExternalAssetOwnerTransfer savedTransfer = 
savedTransferCaptor.getValue();
         assertNotNull(savedTransfer);
         assertFalse(transfers.isEmpty());
-        ExternalAssetOwnerTransfer expectedEffectiveTransfer = 
transfers.get(0);
+        ExternalAssetOwnerTransfer expectedEffectiveTransfer = 
transfers.getFirst();
         assertEquals(testContext.transferExternalId, 
savedTransfer.getExternalId().getValue());
         assertEquals(expectedEffectiveTransfer.getOwner(), 
savedTransfer.getOwner());
         assertEquals(expectedStatus, savedTransfer.getStatus());
@@ -867,6 +956,9 @@ public class ExternalAssetOwnersWriteServiceTest {
         @Mock
         private ExternalAssetOwnersReadService externalAssetOwnersReadService;
 
+        @Mock
+        private ExternalAssetOwnerHelper externalAssetOwnerHelper;
+
         @InjectMocks
         private ExternalAssetOwnersWriteServiceImpl 
externalAssetOwnersWriteServiceImpl;
 
@@ -938,7 +1030,6 @@ public class ExternalAssetOwnersWriteServiceTest {
             
lenient().when(configurationDomainService.getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer())
                     .thenReturn(List.of("ACTIVE", "TRANSFER_IN_PROGRESS", 
"TRANSFER_ON_HOLD", "OVERPAID", "CLOSED_OBLIGATIONS_MET"));
             
lenient().when(externalAssetOwnersReadService.retrieveActiveTransferData(any(Long.class),
 any(), any())).thenReturn(null);
-
         }
     }
 }
diff --git 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java
 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java
index 7262613a93..b6bf1b4dca 100644
--- 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java
+++ 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java
@@ -46,12 +46,17 @@ import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatterBuilder;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import lombok.RequiredArgsConstructor;
 import okhttp3.ResponseBody;
 import org.apache.fineract.accounting.common.AccountingConstants;
@@ -1361,6 +1366,78 @@ public class InitiateExternalAssetOwnerTransferTest 
extends BaseLoanIntegrationT
         });
     }
 
+    @Test
+    public void saleTransferWithSameOwnerExternalIdInParallelShouldNotFail() {
+        try {
+            
globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID,
 true);
+            setInitialBusinessDate("2020-03-02");
+
+            final int threadCount = 10;
+            final String sharedOwnerExternalId = UUID.randomUUID().toString();
+
+            final List<Integer> loanIDs = new ArrayList<>();
+            for (int i = 0; i < threadCount; i++) {
+                Integer clientID = createClient();
+                Integer loanID = createLoanForClient(clientID);
+                loanIDs.add(loanID);
+            }
+
+            final ExecutorService executorService = 
Executors.newFixedThreadPool(threadCount);
+            final CountDownLatch startLatch = new CountDownLatch(1);
+            final CountDownLatch doneLatch = new CountDownLatch(threadCount);
+            final List<PostInitiateTransferResponse> results = 
Collections.synchronizedList(new ArrayList<>());
+            final List<Exception> exceptions = 
Collections.synchronizedList(new ArrayList<>());
+
+            for (int i = 0; i < threadCount; i++) {
+                final Integer loanID = loanIDs.get(i);
+                executorService.execute(() -> {
+                    try {
+                        startLatch.await();
+                        PostInitiateTransferResponse response = 
EXTERNAL_ASSET_OWNER_HELPER.initiateTransferByLoanId(loanID.longValue(),
+                                "sale",
+                                new 
ExternalAssetOwnerRequest().settlementDate("2020-03-02").dateFormat("yyyy-MM-dd").locale("en")
+                                        
.transferExternalId(UUID.randomUUID().toString())
+                                        
.transferExternalGroupId(UUID.randomUUID().toString()).ownerExternalId(sharedOwnerExternalId)
+                                        .purchasePriceRatio("1.0"));
+                        results.add(response);
+                    } catch (Exception e) {
+                        exceptions.add(e);
+                    } finally {
+                        doneLatch.countDown();
+                    }
+                });
+            }
+
+            startLatch.countDown();
+            assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "All threads 
should complete within timeout");
+            executorService.shutdown();
+            assertTrue(executorService.awaitTermination(10, TimeUnit.SECONDS), 
"ExecutorService should terminate");
+
+            assertTrue(exceptions.isEmpty(),
+                    "Expected no exceptions but got " + exceptions.size() + ": 
" + exceptions.stream().map(Throwable::getMessage).toList());
+            assertEquals(threadCount, results.size(), "All transfers should 
succeed");
+            results.forEach(response -> {
+                assertNotNull(response.getResourceId());
+                assertNotNull(response.getResourceExternalId());
+            });
+
+            // Verify all transfers reference the same owner
+            for (Integer loanID : loanIDs) {
+                PageExternalTransferData transfers = 
EXTERNAL_ASSET_OWNER_HELPER.retrieveTransfersByLoanId(loanID.longValue());
+                assertEquals(1, transfers.getTotalElements());
+                assertNotNull(transfers.getContent());
+                assertNotNull(transfers.getContent().getFirst().getOwner());
+                assertEquals(sharedOwnerExternalId, 
transfers.getContent().getFirst().getOwner().getExternalId(),
+                        "All transfers should reference the same owner");
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        } finally {
+            cleanUpAndRestoreBusinessDate();
+        }
+    }
+
     private void updateBusinessDateAndExecuteCOBJob(String date) {
         BusinessDateHelper.updateBusinessDate(REQUEST_SPEC, RESPONSE_SPEC, 
BUSINESS_DATE, LocalDate.parse(date));
         SCHEDULER_JOB_HELPER.executeAndAwaitJob("Loan COB");


Reply via email to