From bc12b2e6743ad17830a526e6aa281d3074d1a713 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Mon, 16 Mar 2026 22:55:29 +0100 Subject: [PATCH] FINERACT-2541: owner to owner transfer functionality --- .../AssetExternalization-Part1.feature | 5 +- .../LoanAccountOwnerTransferBusinessStep.java | 59 +++- .../ExternalAssetOwnersWriteServiceImpl.java | 28 +- ...nAccountOwnerTransferBusinessStepTest.java | 2 + .../ExternalAssetOwnersWriteServiceTest.java | 6 +- ...ExternalAssetOwnerToOwnerTransferTest.java | 281 ++++++++++++++++++ ...nitiateExternalAssetOwnerTransferTest.java | 14 +- 7 files changed, 351 insertions(+), 44 deletions(-) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerToOwnerTransferTest.java diff --git a/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization-Part1.feature b/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization-Part1.feature index acdda043732..6dae47c52f6 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization-Part1.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/AssetExternalization-Part1.feature @@ -281,7 +281,7 @@ Feature: Asset Externalization - Part1 | sale | 2023-05-21 | 1 | @TestRailId:C2735 - Scenario: Verify that SALES request on a loan with ACTIVE ownership results an error + Scenario: Verify that SALES request on a loan with ACTIVE ownership succeeds (owner-to-owner transfer) When Admin sets the business date to "1 May 2023" When Admin creates a client with random data When Admin creates a new default Loan with date: "1 May 2023" @@ -304,9 +304,10 @@ Feature: Asset Externalization - Part1 | 2023-05-21 | 1 | PENDING | 2023-05-01 | 2023-05-21 | SALE | | 2023-05-21 | 1 | ACTIVE | 2023-05-22 | 9999-12-31 | SALE | When Admin sets the business date to "25 May 2023" - Then Asset externalization transaction with the following data results a 403 error and "ASSET_OWNED_CANNOT_BE_SOLD" error message + When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data: | Transaction type | settlementDate | purchasePriceRatio | | sale | 2023-05-30 | 1 | + Then Asset externalization response has the correct Loan ID, transferExternalId @TestRailId:C2736 Scenario: Verify that BUYBACK request on a fully paid loan can be done successfully diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java b/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java index 7c0285e5eaa..d9987ae0536 100644 --- a/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStep.java @@ -46,6 +46,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster; import org.springframework.context.annotation.Conditional; import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; @Component @@ -173,21 +174,20 @@ private ExternalAssetOwnerTransfer sellAsset(final Loan loan, final LocalDate se ExternalAssetOwner previousOwner = determinePreviousOwnerAndCleanupIfNeeded(loan, settlementDate, externalAssetOwnerTransfer); ExternalTransferStatus activeStatus = determineActiveStatus(externalAssetOwnerTransfer); - ExternalAssetOwnerTransfer newTransfer = activatePendingEntry(settlementDate, externalAssetOwnerTransfer, activeStatus); + ExternalAssetOwnerTransfer newTransfer = activatePendingEntry(settlementDate, externalAssetOwnerTransfer, activeStatus, + previousOwner); + loanJournalEntryPoster.postJournalEntriesForExternalOwnerTransfer(loan, newTransfer, previousOwner); return newTransfer; } private ExternalAssetOwner determinePreviousOwnerAndCleanupIfNeeded(final Loan loan, final LocalDate settlementDate, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { - if (!delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId())) { - // When delayed settlement is disabled, asset is directly sold to investor, and we are the previous owner. - return null; - } - - if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) { - // When delayed settlement is enabled and asset is sold to intermediate, we are the previous owner. - return null; + if (!delayedSettlementAttributeService.isEnabled(loan.getLoanProduct().getId()) + || ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) { + // Use the loan mapping as the source of truth for the current owner. + // If a mapping exists, this is an owner-to-owner transfer — expire the current active and clean up. + return expireCurrentOwnerIfPresent(loan, settlementDate); } // When delayed settlement is enabled and asset is sold from intermediate to investor, the intermediate is the @@ -199,6 +199,19 @@ private ExternalAssetOwner determinePreviousOwnerAndCleanupIfNeeded(final Loan l return activeIntermediateTransfer.getOwner(); } + @Nullable + private ExternalAssetOwner expireCurrentOwnerIfPresent(final Loan loan, final LocalDate settlementDate) { + Optional activeTransfer = externalAssetOwnerTransferRepository.findActiveByLoanId(loan.getId()); + if (activeTransfer.isPresent()) { + ExternalAssetOwnerTransfer currentActiveTransfer = activeTransfer.get(); + expireTransfer(settlementDate, currentActiveTransfer); + externalAssetOwnerTransferLoanMappingRepository.deleteByLoanIdAndOwnerTransfer(loan.getId(), currentActiveTransfer); + return currentActiveTransfer.getOwner(); + } + // Internal-to-external transfer: no previous external owner + return null; + } + private ExternalTransferStatus determineActiveStatus(final ExternalAssetOwnerTransfer externalAssetOwnerTransfer) { if (ExternalTransferStatus.PENDING_INTERMEDIATE == externalAssetOwnerTransfer.getStatus()) { return ExternalTransferStatus.ACTIVE_INTERMEDIATE; @@ -208,13 +221,16 @@ private ExternalTransferStatus determineActiveStatus(final ExternalAssetOwnerTra } private ExternalAssetOwnerTransfer getActiveIntermediateOrThrow(final Loan loan) { - Optional optionalActiveIntermediateTransfer = externalAssetOwnerTransferRepository + Optional optionalActiveIntermediateTransfer = findActiveIntermediateTransfer(loan); + return optionalActiveIntermediateTransfer + .orElseThrow(() -> new IllegalStateException("Expected a effective transfer of ACTIVE_INTERMEDIATE status to be present.")); + } + + private Optional findActiveIntermediateTransfer(final Loan loan) { + return externalAssetOwnerTransferRepository .findOne((root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loan.getId()), criteriaBuilder.equal(root.get("status"), ExternalTransferStatus.ACTIVE_INTERMEDIATE), criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31))); - - return optionalActiveIntermediateTransfer - .orElseThrow(() -> new IllegalStateException("Expected a effective transfer of ACTIVE_INTERMEDIATE status to be present.")); } private ExternalAssetOwnerTransferDetails createAssetOwnerTransferDetails(Loan loan, @@ -253,9 +269,11 @@ private ExternalAssetOwnerTransfer cancelTransfer(final LocalDate settlementDate } private ExternalAssetOwnerTransfer activatePendingEntry(final LocalDate settlementDate, - final ExternalAssetOwnerTransfer pendingTransfer, final ExternalTransferStatus activeStatus) { + final ExternalAssetOwnerTransfer pendingTransfer, final ExternalTransferStatus activeStatus, + final ExternalAssetOwner previousOwner) { LocalDate effectiveFrom = settlementDate.plusDays(1); - return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, activeStatus, null, effectiveFrom, FUTURE_DATE_9999_12_31); + return createNewEntryAndExpireOldEntry(settlementDate, pendingTransfer, activeStatus, null, effectiveFrom, FUTURE_DATE_9999_12_31, + previousOwner); } private ExternalAssetOwnerTransfer declinePendingEntry(final Loan loan, final LocalDate settlementDate, @@ -267,6 +285,14 @@ private ExternalAssetOwnerTransfer declinePendingEntry(final Loan loan, final Lo private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDate settlementDate, final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, final ExternalTransferStatus status, final ExternalTransferSubStatus subStatus, final LocalDate effectiveDateFrom, final LocalDate effectiveDateTo) { + return createNewEntryAndExpireOldEntry(settlementDate, externalAssetOwnerTransfer, status, subStatus, effectiveDateFrom, + effectiveDateTo, null); + } + + private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDate settlementDate, + final ExternalAssetOwnerTransfer externalAssetOwnerTransfer, final ExternalTransferStatus status, + final ExternalTransferSubStatus subStatus, final LocalDate effectiveDateFrom, final LocalDate effectiveDateTo, + final ExternalAssetOwner previousOwner) { ExternalAssetOwnerTransfer newExternalAssetOwnerTransfer = new ExternalAssetOwnerTransfer(); newExternalAssetOwnerTransfer.setOwner(externalAssetOwnerTransfer.getOwner()); newExternalAssetOwnerTransfer.setExternalId(externalAssetOwnerTransfer.getExternalId()); @@ -279,7 +305,8 @@ private ExternalAssetOwnerTransfer createNewEntryAndExpireOldEntry(final LocalDa newExternalAssetOwnerTransfer.setPurchasePriceRatio(externalAssetOwnerTransfer.getPurchasePriceRatio()); newExternalAssetOwnerTransfer.setEffectiveDateFrom(effectiveDateFrom); newExternalAssetOwnerTransfer.setEffectiveDateTo(effectiveDateTo); - newExternalAssetOwnerTransfer.setPreviousOwner(externalAssetOwnerTransfer.getPreviousOwner()); + newExternalAssetOwnerTransfer + .setPreviousOwner(previousOwner != null ? previousOwner : externalAssetOwnerTransfer.getPreviousOwner()); expireTransfer(settlementDate, externalAssetOwnerTransfer); 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 13ee21e47db..186a7d40f98 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 @@ -178,16 +178,15 @@ private void validateEffectiveTransferForSale(final List 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.getFirst().getStatus())) { + ExternalAssetOwnerTransfer transfer = effectiveTransfers.getFirst(); + ExternalTransferStatus transferStatus = transfer.getStatus(); + if (PENDING_INTERMEDIATE.equals(transferStatus)) { throw new ExternalAssetOwnerInitiateTransferException( "External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan"); - } 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 { + } + if (!ExternalTransferStatus.ACTIVE.equals(transferStatus)) { throw new ExternalAssetOwnerInitiateTransferException( - String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)", - effectiveTransfers.getFirst().getId())); + String.format("This loan cannot be sold, because it is incorrect state! (transferId = %s)", transfer.getId())); } + // Owner-to-owner transfer with delayed settlement: allow intermediarySale when loan is currently + // owned. The actual ownership switch happens atomically in the COB step. } } diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java index a102ea6394f..6df17dcabf6 100644 --- a/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java +++ b/fineract-investor/src/test/java/org/apache/fineract/investor/cob/loan/LoanAccountOwnerTransferBusinessStepTest.java @@ -494,6 +494,8 @@ public void testSaleLoanWithDelayedSettlementFromIntermediateToInvestor() { ArgumentCaptor externalAssetOwnerTransferArgumentCaptor = ArgumentCaptor .forClass(ExternalAssetOwnerTransfer.class); + // 3 saves: activeIntermediateTransfer (expire), pendingTransfer (expire), activeTransfer (new, with previous + // owner set) verify(externalAssetOwnerTransferRepository, times(3)).save(externalAssetOwnerTransferArgumentCaptor.capture()); ExternalAssetOwnerTransfer capturedActiveIntermediateTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(0); ExternalAssetOwnerTransfer capturedPendingTransfer = externalAssetOwnerTransferArgumentCaptor.getAllValues().get(1); 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 7ab96df1cd6..0646ad4e8c7 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 @@ -392,9 +392,7 @@ private static Stream effectiveTransferDataProviderIntermediarySaleTe Arguments.of("Already In Progress", List.of(activeIntermediate, active), "This loan cannot be sold, there is already an in progress transfer"), Arguments.of("Already Pending Intermediary", List.of(pendingIntermediate), - "External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan"), - Arguments.of("Already Owned by External Asset Owner", List.of(active), - "This loan cannot be sold, because it is owned by an external asset owner")); + "External asset owner transfer is already in PENDING_INTERMEDIATE state for this loan")); } private static Stream loanStatusValidationDataProviderValidActive() { @@ -744,8 +742,6 @@ private static Stream invalidTransferStatusDataProvider() { return Stream.of( Arguments.of(ExternalTransferStatus.PENDING, false, "External asset owner transfer is already in PENDING state for this loan"), - Arguments.of(ExternalTransferStatus.ACTIVE, false, - "This loan cannot be sold, because it is owned by an external asset owner"), Arguments.of(ExternalTransferStatus.PENDING_INTERMEDIATE, true, "This loan cannot be sold, because it is not in ACTIVE-INTERMEDIATE state."), Arguments.of(ExternalTransferStatus.ACTIVE, true, diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerToOwnerTransferTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerToOwnerTransferTest.java new file mode 100644 index 00000000000..c18cce5af10 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerToOwnerTransferTest.java @@ -0,0 +1,281 @@ +/** + * 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.integrationtests.investor.externalassetowner; + +import static org.apache.fineract.client.models.ExternalTransferData.StatusEnum.ACTIVE; +import static org.apache.fineract.client.models.ExternalTransferData.StatusEnum.CANCELLED; +import static org.apache.fineract.client.models.ExternalTransferData.StatusEnum.DECLINED; +import static org.apache.fineract.client.models.ExternalTransferData.StatusEnum.PENDING; +import static org.apache.fineract.client.models.ExternalTransferData.SubStatusEnum.BALANCE_ZERO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.util.UUID; +import org.apache.fineract.client.models.ExternalTransferData; +import org.apache.fineract.client.models.PageExternalTransferData; +import org.apache.fineract.client.models.PostInitiateTransferResponse; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for owner-to-owner (external-to-external) asset transfer functionality. + * + * Tests the flow where a loan currently owned by External Owner A is sold directly to External Owner B without a + * buyback-then-sale cycle. + */ +public class ExternalAssetOwnerToOwnerTransferTest extends ExternalAssetOwnerTransferTest { + + @Test + public void saleFromOwnerAToOwnerBWithoutDelayedSettlement() { + try { + globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, true); + setInitialBusinessDate(java.time.LocalDate.parse("2020-03-02")); + + // Step 1: Create client and loan + Integer clientID = createClient(); + Integer loanID = createLoanForClient(clientID, "02 March 2020"); + addPenaltyForLoan(loanID, "10"); + + // Step 2: Sell loan to Owner A + String ownerAExternalId = UUID.randomUUID().toString(); + String saleATransferExternalId = UUID.randomUUID().toString(); + PostInitiateTransferResponse saleAResponse = createSaleTransfer(loanID, "2020-03-02", saleATransferExternalId, + UUID.randomUUID().toString(), ownerAExternalId, "1.0"); + validateResponse(saleAResponse, loanID); + + // Step 3: Execute COB to activate Owner A's transfer + updateBusinessDateAndExecuteCOBJob("2020-03-03"); + + // Verify Owner A is now ACTIVE + getAndValidateExternalAssetOwnerTransferByLoan(loanID, + ExpectedExternalTransferData.expected(PENDING, saleATransferExternalId, "2020-03-02", "2020-03-02", "2020-03-02", false, + new BigDecimal("15767.420000"), new BigDecimal("15000.000000"), new BigDecimal("757.420000"), + new BigDecimal("10.000000"), new BigDecimal("0.000000"), new BigDecimal("0.000000")), + ExpectedExternalTransferData.expected(ACTIVE, saleATransferExternalId, "2020-03-02", "2020-03-03", "9999-12-31", true, + new BigDecimal("15767.420000"), new BigDecimal("15000.000000"), new BigDecimal("757.420000"), + new BigDecimal("10.000000"), new BigDecimal("0.000000"), new BigDecimal("0.000000"))); + getAndValidateThereIsActiveMapping(loanID); + + // Step 4: Sell loan from Owner A to Owner B (owner-to-owner transfer) + // Settlement date = current business date (2020-03-03), COB will process it on next day + String ownerBExternalId = UUID.randomUUID().toString(); + String saleBTransferExternalId = UUID.randomUUID().toString(); + PostInitiateTransferResponse saleBResponse = createSaleTransfer(loanID, "2020-03-03", saleBTransferExternalId, + UUID.randomUUID().toString(), ownerBExternalId, "1.0"); + validateResponse(saleBResponse, loanID); + + // Verify the new PENDING transfer was created for Owner B + PageExternalTransferData transfers = EXTERNAL_ASSET_OWNER_HELPER.retrieveTransfersByLoanId(loanID.longValue()); + assertNotNull(transfers.getContent()); + + // Find the new PENDING transfer for Owner B + ExternalTransferData pendingTransferB = transfers.getContent().stream() + .filter(t -> saleBTransferExternalId.equals(t.getTransferExternalId()) && PENDING.equals(t.getStatus())).findFirst() + .orElse(null); + assertNotNull(pendingTransferB, "PENDING transfer for Owner B should exist"); + + // Step 5: Execute COB to activate Owner B's transfer (expires Owner A, activates Owner B) + updateBusinessDateAndExecuteCOBJob("2020-03-04"); + + // Verify final state: Owner A's ACTIVE transfer is expired, Owner B is now ACTIVE + transfers = EXTERNAL_ASSET_OWNER_HELPER.retrieveTransfersByLoanId(loanID.longValue()); + assertNotNull(transfers.getContent()); + + // Owner A's ACTIVE transfer should now have effectiveDateTo = 2020-03-03 (settlement date of Owner B) + ExternalTransferData expiredOwnerATransfer = transfers.getContent().stream() + .filter(t -> saleATransferExternalId.equals(t.getTransferExternalId()) && ACTIVE.equals(t.getStatus())).findFirst() + .orElse(null); + assertNotNull(expiredOwnerATransfer, "Expired ACTIVE transfer for Owner A should exist"); + assertEquals(java.time.LocalDate.parse("2020-03-03"), expiredOwnerATransfer.getEffectiveTo(), + "Owner A's ACTIVE transfer should be expired to the settlement date of Owner B's transfer"); + + // Owner B should have an ACTIVE transfer with effectiveDateTo = 9999-12-31 + ExternalTransferData activeOwnerBTransfer = transfers.getContent().stream() + .filter(t -> saleBTransferExternalId.equals(t.getTransferExternalId()) && ACTIVE.equals(t.getStatus())).findFirst() + .orElse(null); + assertNotNull(activeOwnerBTransfer, "ACTIVE transfer for Owner B should exist"); + assertEquals(java.time.LocalDate.parse("9999-12-31"), activeOwnerBTransfer.getEffectiveTo(), + "Owner B's ACTIVE transfer should have open-ended effectiveTo"); + assertNotNull(activeOwnerBTransfer.getDetails(), "ACTIVE transfer should have details"); + + // Verify active mapping now points to Owner B (use direct API, not getAndValidateThereIsActiveMapping + // which assumes only one ACTIVE transfer) + ExternalTransferData activeTransfer = EXTERNAL_ASSET_OWNER_HELPER.retrieveActiveTransferByLoanId(loanID.longValue()); + assertNotNull(activeTransfer, "There should be an active transfer mapping"); + assertEquals(saleBTransferExternalId, activeTransfer.getTransferExternalId(), + "Active mapping should point to Owner B's transfer"); + } finally { + cleanUpAndRestoreBusinessDate(); + } + } + + @Test + public void saleFromOwnerAToOwnerBToOwnerCChainedTransfers() { + try { + globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, true); + setInitialBusinessDate(java.time.LocalDate.parse("2020-03-02")); + + // Setup + Integer clientID = createClient(); + Integer loanID = createLoanForClient(clientID, "02 March 2020"); + addPenaltyForLoan(loanID, "10"); + + // Sell to Owner A and activate via COB + String ownerAExternalId = UUID.randomUUID().toString(); + String saleAExternalId = UUID.randomUUID().toString(); + createSaleTransfer(loanID, "2020-03-02", saleAExternalId, UUID.randomUUID().toString(), ownerAExternalId, "1.0"); + updateBusinessDateAndExecuteCOBJob("2020-03-03"); + getAndValidateThereIsActiveMapping(loanID); + + // Sell from Owner A to Owner B (settlementDate = current business date 2020-03-03) + String ownerBExternalId = UUID.randomUUID().toString(); + String saleBExternalId = UUID.randomUUID().toString(); + createSaleTransfer(loanID, "2020-03-03", saleBExternalId, UUID.randomUUID().toString(), ownerBExternalId, "1.0"); + updateBusinessDateAndExecuteCOBJob("2020-03-04"); + + // Verify Owner B is active + ExternalTransferData activeTransfer = EXTERNAL_ASSET_OWNER_HELPER.retrieveActiveTransferByLoanId(loanID.longValue()); + assertEquals(saleBExternalId, activeTransfer.getTransferExternalId()); + + // Sell from Owner B to Owner C (settlementDate = current business date 2020-03-04) + String ownerCExternalId = UUID.randomUUID().toString(); + String saleCExternalId = UUID.randomUUID().toString(); + createSaleTransfer(loanID, "2020-03-04", saleCExternalId, UUID.randomUUID().toString(), ownerCExternalId, "1.0"); + updateBusinessDateAndExecuteCOBJob("2020-03-05"); + + // Verify Owner C is now active + activeTransfer = EXTERNAL_ASSET_OWNER_HELPER.retrieveActiveTransferByLoanId(loanID.longValue()); + assertEquals(saleCExternalId, activeTransfer.getTransferExternalId(), + "Owner C should be the active owner after chained transfers"); + + // Verify Owner B's transfer is expired + PageExternalTransferData allTransfers = EXTERNAL_ASSET_OWNER_HELPER.retrieveTransfersByLoanId(loanID.longValue()); + ExternalTransferData ownerBActive = allTransfers.getContent().stream() + .filter(t -> saleBExternalId.equals(t.getTransferExternalId()) && ACTIVE.equals(t.getStatus())).findFirst() + .orElse(null); + assertNotNull(ownerBActive); + assertEquals(java.time.LocalDate.parse("2020-03-04"), ownerBActive.getEffectiveTo(), + "Owner B's ACTIVE transfer should be expired to Owner C's settlement date"); + } finally { + cleanUpAndRestoreBusinessDate(); + } + } + + @Test + public void ownerToOwnerPendingCancelledBeforeCOBKeepsOriginalOwnerIntact() { + try { + globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, true); + setInitialBusinessDate(java.time.LocalDate.parse("2020-03-02")); + + // Setup: create loan and sell to Owner A + Integer clientID = createClient(); + Integer loanID = createLoanForClient(clientID, "02 March 2020"); + addPenaltyForLoan(loanID, "10"); + + String ownerAExternalId = UUID.randomUUID().toString(); + String saleAExternalId = UUID.randomUUID().toString(); + createSaleTransfer(loanID, "2020-03-02", saleAExternalId, UUID.randomUUID().toString(), ownerAExternalId, "1.0"); + updateBusinessDateAndExecuteCOBJob("2020-03-03"); + getAndValidateThereIsActiveMapping(loanID); + + // Initiate owner-to-owner sale to Owner B + String ownerBExternalId = UUID.randomUUID().toString(); + String saleBExternalId = UUID.randomUUID().toString(); + PostInitiateTransferResponse saleBResponse = createSaleTransfer(loanID, "2020-03-03", saleBExternalId, + UUID.randomUUID().toString(), ownerBExternalId, "1.0"); + validateResponse(saleBResponse, loanID); + + // Cancel the PENDING transfer for Owner B before COB runs + EXTERNAL_ASSET_OWNER_HELPER.cancelTransferByTransferExternalId(saleBExternalId); + + // Verify Owner A is still the active owner — ACTIVE transfer with effectiveDateTo = 9999-12-31 + ExternalTransferData activeTransfer = EXTERNAL_ASSET_OWNER_HELPER.retrieveActiveTransferByLoanId(loanID.longValue()); + assertNotNull(activeTransfer, "Owner A should still be the active owner after cancel"); + assertEquals(saleAExternalId, activeTransfer.getTransferExternalId(), + "Active mapping should still point to Owner A after cancel"); + + // Verify Owner A's ACTIVE transfer is untouched (effectiveDateTo = 9999-12-31) + PageExternalTransferData transfers = EXTERNAL_ASSET_OWNER_HELPER.retrieveTransfersByLoanId(loanID.longValue()); + ExternalTransferData ownerAActive = transfers.getContent().stream() + .filter(t -> saleAExternalId.equals(t.getTransferExternalId()) && ACTIVE.equals(t.getStatus()) + && java.time.LocalDate.parse("9999-12-31").equals(t.getEffectiveTo())) + .findFirst().orElse(null); + assertNotNull(ownerAActive, "Owner A's ACTIVE transfer should still have open-ended effectiveTo"); + + // Verify Owner B's transfer is CANCELLED + ExternalTransferData ownerBCancelled = transfers.getContent().stream() + .filter(t -> saleBExternalId.equals(t.getTransferExternalId()) && CANCELLED.equals(t.getStatus())).findFirst() + .orElse(null); + assertNotNull(ownerBCancelled, "Owner B's transfer should be CANCELLED"); + } finally { + cleanUpAndRestoreBusinessDate(); + } + } + + @Test + public void ownerToOwnerPendingDeclinedInCOBKeepsOriginalOwnerIntact() { + try { + globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, true); + setInitialBusinessDate(java.time.LocalDate.parse("2020-03-02")); + + // Setup: create loan and sell to Owner A + Integer clientID = createClient(); + Integer loanID = createLoanForClient(clientID, "02 March 2020"); + addPenaltyForLoan(loanID, "10"); + + String ownerAExternalId = UUID.randomUUID().toString(); + String saleAExternalId = UUID.randomUUID().toString(); + createSaleTransfer(loanID, "2020-03-02", saleAExternalId, UUID.randomUUID().toString(), ownerAExternalId, "1.0"); + updateBusinessDateAndExecuteCOBJob("2020-03-03"); + getAndValidateThereIsActiveMapping(loanID); + + // Initiate owner-to-owner sale to Owner B with future settlement date + String ownerBExternalId = UUID.randomUUID().toString(); + String saleBExternalId = UUID.randomUUID().toString(); + createSaleTransfer(loanID, "2020-03-06", saleBExternalId, UUID.randomUUID().toString(), ownerBExternalId, "1.0"); + + // Write off the loan — this will trigger decline when COB processes the PENDING transfer + updateBusinessDateAndExecuteCOBJob("2020-03-04"); + LOAN_TRANSACTION_HELPER.writeOffLoan("04 March 2020", loanID); + + // Verify Owner A is still the active owner (PENDING for Owner B should be declined, not yet settled) + ExternalTransferData activeTransfer = EXTERNAL_ASSET_OWNER_HELPER.retrieveActiveTransferByLoanId(loanID.longValue()); + assertNotNull(activeTransfer, "Owner A should still be the active owner before settlement date"); + assertEquals(saleAExternalId, activeTransfer.getTransferExternalId(), "Active mapping should still point to Owner A"); + + // Verify Owner B's PENDING transfer is declined + PageExternalTransferData transfers = EXTERNAL_ASSET_OWNER_HELPER.retrieveTransfersByLoanId(loanID.longValue()); + ExternalTransferData ownerBDeclined = transfers.getContent().stream() + .filter(t -> saleBExternalId.equals(t.getTransferExternalId()) && DECLINED.equals(t.getStatus())).findFirst() + .orElse(null); + assertNotNull(ownerBDeclined, "Owner B's transfer should be DECLINED"); + assertEquals(BALANCE_ZERO, ownerBDeclined.getSubStatus(), "Decline reason should be BALANCE_ZERO"); + + // Verify Owner A's ACTIVE transfer is still open-ended + ExternalTransferData ownerAActive = transfers.getContent().stream() + .filter(t -> saleAExternalId.equals(t.getTransferExternalId()) && ACTIVE.equals(t.getStatus()) + && java.time.LocalDate.parse("9999-12-31").equals(t.getEffectiveTo())) + .findFirst().orElse(null); + assertNotNull(ownerAActive, "Owner A's ACTIVE transfer should still have open-ended effectiveTo after decline"); + } finally { + cleanUpAndRestoreBusinessDate(); + } + } +} 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 be2b61e1c5d..08d426b1a07 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 @@ -933,13 +933,13 @@ public void saleExceptionHandling() { createSaleTransfer(loanID, "2020-03-05"); }); assertTrue(exception5.getMessage().contains("This loan cannot be sold, there is already an in progress transfer")); - CallFailedRuntimeException exception6 = assertThrows(CallFailedRuntimeException.class, () -> { - Integer loanID2 = createLoanForClient(clientID); - createSaleTransfer(loanID2, "2020-03-03"); - updateBusinessDateAndExecuteCOBJob("2020-03-04"); - createSaleTransfer(loanID2, "2020-03-05"); - }); - assertTrue(exception6.getMessage().contains("This loan cannot be sold, because it is owned by an external asset owner")); + // Owner-to-owner transfer: selling a loan that is already owned by an external asset owner should + // succeed at API time (a new PENDING is created; the actual ownership switch happens in COB) + Integer loanIDForOwnerTransfer = createLoanForClient(clientID); + createSaleTransfer(loanIDForOwnerTransfer, "2020-03-03"); + updateBusinessDateAndExecuteCOBJob("2020-03-04"); + PostInitiateTransferResponse ownerToOwnerSaleResponse = createSaleTransfer(loanIDForOwnerTransfer, "2020-03-05"); + assertNotNull(ownerToOwnerSaleResponse.getResourceId()); String externalId = UUID.randomUUID().toString(); String transferExternalGroupId = UUID.randomUUID().toString(); CallFailedRuntimeException exception7 = assertThrows(CallFailedRuntimeException.class, () -> {