FINERACT-2181: Fix the interest calculation for non active loan

This commit is contained in:
Attila Budai
2025-07-31 19:59:15 +02:00
committed by Adam Saghy
parent 3e2684c0d6
commit 0762a012e5
13 changed files with 517 additions and 21 deletions
@@ -77,6 +77,8 @@ public final class GlobalConfigurationConstants {
public static final String ENABLE_PAYMENT_HUB_INTEGRATION = "enable-payment-hub-integration";
public static final String ENABLE_IMMEDIATE_CHARGE_ACCRUAL_POST_MATURITY = "enable-immediate-charge-accrual-post-maturity";
public static final String ASSET_OWNER_TRANSFER_OUTSTANDING_INTEREST_CALCULATION_STRATEGY = "outstanding-interest-calculation-strategy-for-external-asset-transfer";
public static final String ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-for-external-asset-transfer";
public static final String ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer";
private GlobalConfigurationConstants() {}
}
@@ -19,12 +19,17 @@
package org.apache.fineract.infrastructure.configuration.domain;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.cache.domain.CacheType;
public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
List<String> getAllowedLoanStatusesForExternalAssetTransfer();
List<String> getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer();
boolean isSameMakerCheckerEnabled();
boolean isAmazonS3Enabled();
@@ -49,6 +49,11 @@ public class ExternalAssetOwnerTransferOutstandingInterestCalculation {
}
public BigDecimal calculateOutstandingInterest(Loan loan) {
// If loan is not active, there should be no outstanding interest
if (!loan.isOpen()) {
return BigDecimal.ZERO;
}
String outstandingInterestCalculationStrategy = configurationDomainService.getAssetOwnerTransferOustandingInterestStrategy();
return switch (outstandingInterestCalculationStrategy) {
case "TOTAL_OUTSTANDING_INTEREST" -> loan.getSummary().getTotalInterestOutstanding();
@@ -21,11 +21,6 @@ package org.apache.fineract.investor.service;
import static org.apache.fineract.investor.data.ExternalTransferStatus.ACTIVE_INTERMEDIATE;
import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING;
import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING_INTERMEDIATE;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.ACTIVE;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.CLOSED_OBLIGATIONS_MET;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.OVERPAID;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.TRANSFER_IN_PROGRESS;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.TRANSFER_ON_HOLD;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
@@ -43,6 +38,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.cob.data.LoanDataForExternalTransfer;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
@@ -74,9 +70,6 @@ import org.springframework.transaction.annotation.Transactional;
public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersWriteService {
private static final LocalDate FUTURE_DATE_9999_12_31 = LocalDate.of(9999, 12, 31);
private static final List<LoanStatus> ACTIVE_LOAN_STATUSES = List.of(ACTIVE, TRANSFER_IN_PROGRESS, TRANSFER_ON_HOLD);
private static final List<LoanStatus> VALID_DELAYED_SETTLEMENT_LOAN_STATUSES_BUYBACK_AND_SALE = List.of(ACTIVE, TRANSFER_IN_PROGRESS,
TRANSFER_ON_HOLD, OVERPAID, CLOSED_OBLIGATIONS_MET);
private static final List<ExternalTransferStatus> BUYBACK_READY_STATUSES = List.of(ExternalTransferStatus.PENDING,
ExternalTransferStatus.ACTIVE);
private static final List<ExternalTransferStatus> BUYBACK_READY_STATUSES_FOR_DELAY_SETTLEMENT = List
@@ -86,6 +79,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
private final FromJsonHelper fromApiJsonHelper;
private final LoanRepository loanRepository;
private final DelayedSettlementAttributeService delayedSettlementAttributeService;
private final ConfigurationDomainService configurationDomainService;
@Override
@Transactional
@@ -388,16 +382,16 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
private void validateLoanStatusIntermediarySale(LoanDataForExternalTransfer loanDataForExternalTransfer) {
LoanStatus loanStatus = loanDataForExternalTransfer.getLoanStatus();
if (!ACTIVE_LOAN_STATUSES.contains(loanStatus)) {
if (!getAllowedLoanStatuses().contains(loanStatus)) {
throw new ExternalAssetOwnerInitiateTransferException(String.format("Loan status %s is not valid for transfer.", loanStatus));
}
}
private List<LoanStatus> getValidLoanStatusList(boolean isDelayedSettlementEnabled) {
if (isDelayedSettlementEnabled) {
return VALID_DELAYED_SETTLEMENT_LOAN_STATUSES_BUYBACK_AND_SALE;
return getAllowedLoanStatusesForDelayedSettlement();
} else {
return ACTIVE_LOAN_STATUSES;
return getAllowedLoanStatuses();
}
}
@@ -577,4 +571,14 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
externalAssetOwner.setExternalId(ExternalIdFactory.produce(externalId));
return externalAssetOwnerRepository.saveAndFlush(externalAssetOwner);
}
private List<LoanStatus> getAllowedLoanStatuses() {
return configurationDomainService.getAllowedLoanStatusesForExternalAssetTransfer().stream().map(LoanStatus::valueOf)
.collect(Collectors.toList());
}
private List<LoanStatus> getAllowedLoanStatusesForDelayedSettlement() {
return configurationDomainService.getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer().stream()
.map(LoanStatus::valueOf).collect(Collectors.toList());
}
}
@@ -40,4 +40,5 @@
<include relativeToChangelogFile="true" file="parts/0016_add_external_reference_id.xml"/>
<include relativeToChangelogFile="true" file="parts/0017_add_external_asset_owner_loan_product_attr_index.xml"/>
<include relativeToChangelogFile="true" file="parts/0018_add_external_asset_owner_transfer_outstanding_interest_strategy.xml"/>
<include relativeToChangelogFile="true" file="parts/0019_add_configurable_allowed_loan_statuses.xml"/>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet author="fineract" id="1">
<insert tableName="c_configuration">
<column name="name" value="allowed-loan-statuses-for-external-asset-transfer"/>
<column name="value"/>
<column name="date_value"/>
<column name="string_value" value="ACTIVE,TRANSFER_IN_PROGRESS,TRANSFER_ON_HOLD"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value=" Available options: Any combination from LoanStatus enum separated by comma."/>
</insert>
<insert tableName="c_configuration">
<column name="name" value="allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer"/>
<column name="value"/>
<column name="date_value"/>
<column name="string_value" value="ACTIVE,TRANSFER_IN_PROGRESS,TRANSFER_ON_HOLD,OVERPAID,CLOSED_OBLIGATIONS_MET"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value=" Available options: Any combination from LoanStatus enum separated by comma."/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,127 @@
/**
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.organisation.monetary.mapper.CurrencyMapper;
import org.apache.fineract.portfolio.loanaccount.data.LoanAccountData;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanSummaryDataProvider;
import org.apache.fineract.portfolio.loanaccount.service.LoanSummaryProviderDelegate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ExternalAssetOwnerTransferOutstandingInterestCalculationTest {
@Mock
private LoanSummaryProviderDelegate loanSummaryDataProvider;
@Mock
private ConfigurationDomainService configurationDomainService;
@Mock
private LoanReadPlatformService loanReadPlatformService;
@Mock
private CurrencyMapper currencyMapper;
@Mock
private Loan loan;
@Mock
private LoanSummary loanSummary;
@Mock
private LoanAccountData loanAccountData;
@Mock
private LoanScheduleData loanScheduleData;
@Mock
private LoanSummaryDataProvider summaryDataProvider;
@InjectMocks
private ExternalAssetOwnerTransferOutstandingInterestCalculation externalAssetOwnerTransferOutstandingInterestCalculation;
@Test
void testCalculateOutstandingInterest_WhenLoanNotDisbursed_ShouldReturnZero() {
// Given
when(loan.isOpen()).thenReturn(false);
// When
BigDecimal result = externalAssetOwnerTransferOutstandingInterestCalculation.calculateOutstandingInterest(loan);
// Then
assertEquals(BigDecimal.ZERO, result);
// Verify that no other calculations were performed
verify(configurationDomainService, never()).getAssetOwnerTransferOustandingInterestStrategy();
verify(loanReadPlatformService, never()).retrieveOne(Mockito.anyLong());
verify(loan, times(1)).isOpen();
}
@Test
void testCalculateOutstandingInterest_WhenLoanDisbursedWithTotalStrategy_ShouldCalculate() {
// Given - ACTIVE loan with TOTAL_OUTSTANDING_INTEREST strategy
when(loan.isOpen()).thenReturn(true);
when(configurationDomainService.getAssetOwnerTransferOustandingInterestStrategy()).thenReturn("TOTAL_OUTSTANDING_INTEREST");
when(loan.getSummary()).thenReturn(loanSummary);
BigDecimal expectedInterest = new BigDecimal("150.50");
when(loanSummary.getTotalInterestOutstanding()).thenReturn(expectedInterest);
// When
BigDecimal result = externalAssetOwnerTransferOutstandingInterestCalculation.calculateOutstandingInterest(loan);
// Then
assertEquals(expectedInterest, result);
verify(loan, times(1)).isOpen();
verify(loan, times(1)).getSummary();
}
@Test
void testCalculateOutstandingInterest_BackdatedUndisbursedLoan_ShouldReturnZero() {
// Given - backdated loan (created 3 months ago) but not disbursed
when(loan.isOpen()).thenReturn(false);
// When
BigDecimal result = externalAssetOwnerTransferOutstandingInterestCalculation.calculateOutstandingInterest(loan);
// Then
assertEquals(BigDecimal.ZERO, result);
// Verify that the method returned early due to disbursement check
verify(loan, times(1)).isOpen();
verify(configurationDomainService, never()).getAssetOwnerTransferOustandingInterestStrategy();
}
}
@@ -50,6 +50,7 @@ import java.util.Optional;
import java.util.stream.Stream;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.fineract.cob.data.LoanDataForExternalTransfer;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
@@ -418,8 +419,8 @@ public class ExternalAssetOwnersWriteServiceTest {
}
private static Stream<Arguments> loanStatusValidationDataProviderInvalidDelayedSettlement() {
return Stream.of(Arguments.of("Invalid Loan Status", LoanStatus.INVALID), Arguments.of("Approved Loan Status", LoanStatus.APPROVED),
Arguments.of("Rejected Loan Status", LoanStatus.REJECTED),
return Stream.of(Arguments.of("Invalid Loan Status", LoanStatus.INVALID), Arguments.of("Rejected Loan Status", LoanStatus.REJECTED),
Arguments.of("Approved Loan Status", LoanStatus.APPROVED),
Arguments.of("Submitted and Pending Approval Loan Status", LoanStatus.SUBMITTED_AND_PENDING_APPROVAL),
Arguments.of("Withdrawn By Client Loan Status", LoanStatus.WITHDRAWN_BY_CLIENT),
Arguments.of("Closed Written Off Loan Status", LoanStatus.CLOSED_WRITTEN_OFF),
@@ -860,6 +861,9 @@ public class ExternalAssetOwnersWriteServiceTest {
@Mock
private LoanDataForExternalTransfer loanDataForExternalTransfer;
@Mock
private ConfigurationDomainService configurationDomainService;
@InjectMocks
private ExternalAssetOwnersWriteServiceImpl externalAssetOwnersWriteServiceImpl;
@@ -926,6 +930,11 @@ public class ExternalAssetOwnersWriteServiceTest {
lenient().when(loanDataForExternalTransfer.getLoanStatus()).thenReturn(LoanStatus.ACTIVE);
lenient().when(loanDataForExternalTransfer.getLoanProductId()).thenReturn(loanProductId);
lenient().when(loanDataForExternalTransfer.getLoanProductShortName()).thenReturn(loanProductShortName);
lenient().when(configurationDomainService.getAllowedLoanStatusesForExternalAssetTransfer())
.thenReturn(List.of("ACTIVE", "TRANSFER_IN_PROGRESS", "TRANSFER_ON_HOLD"));
lenient().when(configurationDomainService.getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer())
.thenReturn(List.of("ACTIVE", "TRANSFER_IN_PROGRESS", "TRANSFER_ON_HOLD", "OVERPAID", "CLOSED_OBLIGATIONS_MET"));
}
}
}
@@ -20,6 +20,7 @@ package org.apache.fineract.infrastructure.configuration.domain;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -60,6 +61,20 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return false;
}
@Override
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
GlobalConfigurationConstants.ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER);
return List.of(property.getStringValue().split(","));
}
@Override
public List<String> getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
GlobalConfigurationConstants.ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER);
return List.of(property.getStringValue().split(","));
}
@Override
public boolean isSameMakerCheckerEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.ENABLE_SAME_MAKER_CHECKER).isEnabled();
@@ -3315,9 +3315,6 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
return Optional.empty();
}
loanLifecycleStateMachine.transition(LoanEvent.WRITE_OFF_OUTSTANDING, loan);
changes.put(PARAM_STATUS, LoanEnumerations.status(loan.getLoanStatus()));
existingTransactionIds.addAll(loanTransactionRepository.findTransactionIdsByLoan(loan));
existingReversedTransactionIds.addAll(loanTransactionRepository.findReversedTransactionIdsByLoan(loan));
@@ -3359,7 +3356,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
new MoneyHolder(loan.getTotalOverpaidAsMoney()), null));
loanBalanceService.updateLoanSummaryDerivedFields(loan);
loanLifecycleStateMachine.transition(LoanEvent.WRITE_OFF_OUTSTANDING, loan);
changes.put(PARAM_STATUS, LoanEnumerations.status(loan.getLoanStatus()));
return Optional.of(loanTransaction);
}
@@ -105,8 +105,8 @@ public class GlobalConfigurationHelper {
ArrayList<HashMap> expectedGlobalConfigurations = getAllDefaultGlobalConfigurations();
GetGlobalConfigurationsResponse actualGlobalConfigurations = getAllGlobalConfigurations();
Assertions.assertEquals(57, expectedGlobalConfigurations.size());
Assertions.assertEquals(57, actualGlobalConfigurations.getGlobalConfiguration().size());
Assertions.assertEquals(59, expectedGlobalConfigurations.size());
Assertions.assertEquals(59, actualGlobalConfigurations.getGlobalConfiguration().size());
for (int i = 0; i < expectedGlobalConfigurations.size(); i++) {
@@ -549,6 +549,25 @@ public class GlobalConfigurationHelper {
assetOwnerTransferInterestOutstandingStrategy.put("string_value", "TOTAL_OUTSTANDING_INTEREST");
defaults.add(assetOwnerTransferInterestOutstandingStrategy);
HashMap<String, Object> allowedLoanStatusesForExternalAssetTransfer = new HashMap<>();
allowedLoanStatusesForExternalAssetTransfer.put("name",
GlobalConfigurationConstants.ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER);
allowedLoanStatusesForExternalAssetTransfer.put("value", 0L);
allowedLoanStatusesForExternalAssetTransfer.put("enabled", true);
allowedLoanStatusesForExternalAssetTransfer.put("trapDoor", false);
allowedLoanStatusesForExternalAssetTransfer.put("string_value", "ACTIVE,TRANSFER_IN_PROGRESS,TRANSFER_ON_HOLD");
defaults.add(allowedLoanStatusesForExternalAssetTransfer);
HashMap<String, Object> allowedLoanStatusesForDelayedSettlementExternalAssetTransfer = new HashMap<>();
allowedLoanStatusesForDelayedSettlementExternalAssetTransfer.put("name",
GlobalConfigurationConstants.ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER);
allowedLoanStatusesForDelayedSettlementExternalAssetTransfer.put("value", 0L);
allowedLoanStatusesForDelayedSettlementExternalAssetTransfer.put("enabled", true);
allowedLoanStatusesForDelayedSettlementExternalAssetTransfer.put("trapDoor", false);
allowedLoanStatusesForDelayedSettlementExternalAssetTransfer.put("string_value",
"ACTIVE,TRANSFER_IN_PROGRESS,TRANSFER_ON_HOLD,OVERPAID,CLOSED_OBLIGATIONS_MET");
defaults.add(allowedLoanStatusesForDelayedSettlementExternalAssetTransfer);
return defaults;
}
@@ -0,0 +1,264 @@
/**
* 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.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
import java.math.BigDecimal;
import java.util.UUID;
import org.apache.fineract.client.models.ExternalAssetOwnerRequest;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostInitiateTransferResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.client.models.PutGlobalConfigurationsRequest;
import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants;
import org.apache.fineract.integrationtests.BaseLoanIntegrationTest;
import org.apache.fineract.integrationtests.common.BusinessStepHelper;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.ExternalAssetOwnerHelper;
import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.accounting.Account;
import org.apache.fineract.integrationtests.common.accounting.FinancialActivityAccountHelper;
import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith({ LoanTestLifecycleExtension.class })
public class ExternalAssetOwnerTransferUndisbursedLoanTest extends BaseLoanIntegrationTest {
private Long loan1Id;
private Long loan2Id;
@BeforeAll
public static void setup() {
new BusinessStepHelper().updateSteps("LOAN_CLOSE_OF_BUSINESS", "APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION",
"CHECK_LOAN_REPAYMENT_DUE", "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES",
"EXTERNAL_ASSET_OWNER_TRANSFER");
new GlobalConfigurationHelper().updateGlobalConfiguration(
GlobalConfigurationConstants.ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER,
new PutGlobalConfigurationsRequest().stringValue("APPROVED,ACTIVE,TRANSFER_IN_PROGRESS,TRANSFER_ON_HOLD"));
}
@AfterAll
public static void tearDown() {
new GlobalConfigurationHelper().updateGlobalConfiguration(
GlobalConfigurationConstants.ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER,
new PutGlobalConfigurationsRequest().stringValue("ACTIVE,TRANSFER_IN_PROGRESS,TRANSFER_ON_HOLD"));
}
@Test
public void testExternalAssetOwnerTransferForUndisbursedLoan() {
globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, true);
Account transferAccount = accountHelper.createAssetAccount();
FinancialActivityAccountHelper financialActivityAccountHelper = new FinancialActivityAccountHelper(requestSpec);
ExternalAssetOwnerHelper externalAssetOwnerHelper = new ExternalAssetOwnerHelper();
externalAssetOwnerHelper.setProperFinancialActivity(financialActivityAccountHelper, transferAccount);
try {
runAt("01 January 2024", () -> {
PostClientsResponse client = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest());
Long clientId = client.getClientId();
PostLoanProductsRequest loanProductRequest = createOnePeriod30DaysPeriodicAccrualProduct(12.0)
.name(Utils.uniqueRandomStringGenerator("UNDISBURSED_TEST_", 4))
.shortName(Utils.uniqueRandomStringGenerator("UT", 2));
PostLoanProductsResponse loanProduct = loanProductHelper.createLoanProduct(loanProductRequest);
PostLoansResponse loanResponse = loanTransactionHelper
.applyLoan(applyLoanRequest(clientId, loanProduct.getResourceId(), "01 January 2024", 10000.0, 4));
Long loanId = loanResponse.getLoanId();
loanTransactionHelper.approveLoan(loanId, approveLoanRequest(10000.0, "01 January 2024"));
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanDetails);
assertEquals("loanStatusType.approved", loanDetails.getStatus().getCode());
String transferExternalId = UUID.randomUUID().toString();
String ownerExternalId = UUID.randomUUID().toString();
PostInitiateTransferResponse transferResponse = externalAssetOwnerHelper.initiateTransferByLoanId(loanId, "sale",
new ExternalAssetOwnerRequest().settlementDate("01 January 2024").dateFormat("dd MMMM yyyy").locale("en")
.transferExternalId(transferExternalId).ownerExternalId(ownerExternalId).purchasePriceRatio("1.0"));
assertNotNull(transferResponse);
assertEquals(transferExternalId, transferResponse.getResourceExternalId());
GetLoansLoanIdResponse loanAfterTransfer = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanAfterTransfer, "Loan details should not be null");
if (loanAfterTransfer.getSummary() == null) {
assertEquals("loanStatusType.approved", loanAfterTransfer.getStatus().getCode(),
"Loan should remain in approved status");
assertEquals(0, BigDecimal.valueOf(10000.0).compareTo(loanAfterTransfer.getApprovedPrincipal()),
"Approved principal should be 10000");
return;
}
fail("Unexpected: Loan summary should be null for undisbursed loans");
});
} finally {
globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, false);
}
}
@Test
public void testExternalAssetOwnerTransferForBackdatedUndisbursedLoan() {
globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, true);
Account transferAccount = accountHelper.createAssetAccount();
FinancialActivityAccountHelper financialActivityAccountHelper = new FinancialActivityAccountHelper(requestSpec);
ExternalAssetOwnerHelper externalAssetOwnerHelper = new ExternalAssetOwnerHelper();
externalAssetOwnerHelper.setProperFinancialActivity(financialActivityAccountHelper, transferAccount);
try {
runAt("01 March 2024", () -> {
PostClientsResponse client = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest());
Long clientId = client.getClientId();
PostLoanProductsRequest loanProductRequest = createOnePeriod30DaysPeriodicAccrualProduct(12.0)
.name(Utils.uniqueRandomStringGenerator("BACKDATED_UNDISBURSED_", 4))
.shortName(Utils.uniqueRandomStringGenerator("BU", 2));
PostLoanProductsResponse loanProduct = loanProductHelper.createLoanProduct(loanProductRequest);
PostLoansResponse loanResponse = loanTransactionHelper
.applyLoan(applyLoanRequest(clientId, loanProduct.getResourceId(), "01 December 2023", 15000.0, 4));
Long loanId = loanResponse.getLoanId();
loanTransactionHelper.approveLoan(loanId, approveLoanRequest(15000.0, "01 December 2023"));
String transferExternalId = UUID.randomUUID().toString();
String ownerExternalId = UUID.randomUUID().toString();
PostInitiateTransferResponse transferResponse = externalAssetOwnerHelper.initiateTransferByLoanId(loanId, "sale",
new ExternalAssetOwnerRequest().settlementDate("01 March 2024").dateFormat("dd MMMM yyyy").locale("en")
.transferExternalId(transferExternalId).ownerExternalId(ownerExternalId).purchasePriceRatio("1.0"));
assertNotNull(transferResponse);
assertEquals(transferExternalId, transferResponse.getResourceExternalId());
GetLoansLoanIdResponse loanAfterTransfer = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanAfterTransfer, "Loan details should not be null");
if (loanAfterTransfer.getSummary() == null) {
assertEquals("loanStatusType.approved", loanAfterTransfer.getStatus().getCode(),
"Loan should remain in approved status");
assertEquals(0, BigDecimal.valueOf(15000.0).compareTo(loanAfterTransfer.getApprovedPrincipal()),
"Approved principal should be 15000");
return;
}
fail("Unexpected: Loan summary should be null for undisbursed loans");
});
} finally {
globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, false);
}
}
@Test
public void testExternalAssetOwnerTransferComparison_DisbursedVsUndisbursed() {
globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, true);
Account transferAccount = accountHelper.createAssetAccount();
FinancialActivityAccountHelper financialActivityAccountHelper = new FinancialActivityAccountHelper(requestSpec);
ExternalAssetOwnerHelper externalAssetOwnerHelper = new ExternalAssetOwnerHelper();
externalAssetOwnerHelper.setProperFinancialActivity(financialActivityAccountHelper, transferAccount);
try {
runAt("01 January 2024", () -> {
PostClientsResponse client1 = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest());
PostClientsResponse client2 = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest());
PostLoanProductsRequest loanProductRequest = createOnePeriod30DaysPeriodicAccrualProduct(12.0)
.name(Utils.uniqueRandomStringGenerator("COMPARISON_TEST_", 4))
.shortName(Utils.uniqueRandomStringGenerator("CT", 2));
PostLoanProductsResponse loanProduct = loanProductHelper.createLoanProduct(loanProductRequest);
PostLoansResponse loan1Response = loanTransactionHelper
.applyLoan(applyLoanRequest(client1.getClientId(), loanProduct.getResourceId(), "01 January 2024", 20000.0, 4));
loan1Id = loan1Response.getLoanId();
PostLoansResponse loan2Response = loanTransactionHelper
.applyLoan(applyLoanRequest(client2.getClientId(), loanProduct.getResourceId(), "01 January 2024", 20000.0, 4));
loan2Id = loan2Response.getLoanId();
loanTransactionHelper.approveLoan(loan1Id, approveLoanRequest(20000.0, "01 January 2024"));
loanTransactionHelper.approveLoan(loan2Id, approveLoanRequest(20000.0, "01 January 2024"));
disburseLoan(loan1Id, BigDecimal.valueOf(20000.0), "01 January 2024");
});
runAt("31 January 2024", () -> {
executeInlineCOB(loan1Id);
executeInlineCOB(loan2Id);
String transfer1ExternalId = UUID.randomUUID().toString();
String transfer2ExternalId = UUID.randomUUID().toString();
String ownerExternalId = UUID.randomUUID().toString();
PostInitiateTransferResponse transfer1Response = externalAssetOwnerHelper.initiateTransferByLoanId(loan1Id, "sale",
new ExternalAssetOwnerRequest().settlementDate("31 January 2024").dateFormat("dd MMMM yyyy").locale("en")
.transferExternalId(transfer1ExternalId).ownerExternalId(ownerExternalId).purchasePriceRatio("1.0"));
PostInitiateTransferResponse transfer2Response = externalAssetOwnerHelper.initiateTransferByLoanId(loan2Id, "sale",
new ExternalAssetOwnerRequest().settlementDate("31 January 2024").dateFormat("dd MMMM yyyy").locale("en")
.transferExternalId(transfer2ExternalId).ownerExternalId(ownerExternalId).purchasePriceRatio("1.0"));
GetLoansLoanIdResponse disbursedLoan = loanTransactionHelper.getLoanDetails(loan1Id);
GetLoansLoanIdResponse undisbursedLoan = loanTransactionHelper.getLoanDetails(loan2Id);
assertNotNull(disbursedLoan, "Disbursed loan details should not be null");
assertNotNull(undisbursedLoan, "Undisbursed loan details should not be null");
assertNotNull(disbursedLoan.getSummary(), "Disbursed loan summary should not be null");
if (undisbursedLoan.getSummary() == null) {
assertEquals("loanStatusType.active", disbursedLoan.getStatus().getCode(), "Disbursed loan should be active");
assertEquals("loanStatusType.approved", undisbursedLoan.getStatus().getCode(),
"Undisbursed loan should remain approved");
BigDecimal disbursedInterest = disbursedLoan.getSummary().getInterestOutstanding();
assertNotNull(disbursedInterest, "Disbursed loan interest outstanding should not be null");
return;
}
fail("Unexpected: Undisbursed loan should not have summary data");
});
} finally {
globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.ENABLE_AUTO_GENERATED_EXTERNAL_ID, false);
}
}
}
@@ -698,8 +698,8 @@ public class InitiateExternalAssetOwnerTransferTest extends BaseLoanIntegrationT
new BigDecimal("757.420000"), new BigDecimal("0.000000"), new BigDecimal("0.000000"),
new BigDecimal("0.000000")),
ExpectedExternalTransferData.expected(BUYBACK, buybackTransferResponse.getResourceExternalId(), "2020-03-06",
"2020-03-05", "2020-03-05", true, new BigDecimal("15757.420000"), new BigDecimal("15000.000000"),
new BigDecimal("757.420000"), new BigDecimal("0.000000"), new BigDecimal("0.000000"),
"2020-03-05", "2020-03-05", true, new BigDecimal("0.000000"), new BigDecimal("0.000000"),
new BigDecimal("0.000000"), new BigDecimal("0.000000"), new BigDecimal("0.000000"),
new BigDecimal("0.000000")));
getAndValidateThereIsNoActiveMapping(saleTransferResponse.getResourceExternalId());
} finally {