FINERACT-1724: Asset externalization of non-active loans

This commit is contained in:
abraham.menyhart
2023-07-24 20:29:08 +02:00
committed by Adam Saghy
parent 7c80bf2ffc
commit 61915bbcc4
17 changed files with 692 additions and 18 deletions
@@ -0,0 +1,21 @@
/**
* 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;
public interface ExternalAssetOwnerLoanStatusChangePlatformService {}
@@ -0,0 +1,57 @@
/**
* 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 jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.service.ConfigurationReadPlatformService;
import org.apache.fineract.infrastructure.event.business.BusinessEventListener;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ExternalAssetOwnerLoanStatusChangePlatformServiceImpl implements ExternalAssetOwnerLoanStatusChangePlatformService {
private final BusinessEventNotifierService businessEventNotifierService;
private final ConfigurationReadPlatformService configurationReadPlatformService;
private final LoanAccountOwnerTransferService loanAccountOwnerTransferService;
private static final String ASSET_EXTERNALIZATION_OF_NON_ACTIVE_LOANS = "asset-externalization-of-non-active-loans";
@PostConstruct
public void addListeners() {
businessEventNotifierService.addPostBusinessEventListener(LoanStatusChangedBusinessEvent.class,
new ExternalAssetOwnerLoanStatusChangedListener());
}
private class ExternalAssetOwnerLoanStatusChangedListener implements BusinessEventListener<LoanStatusChangedBusinessEvent> {
@Override
public void onBusinessEvent(LoanStatusChangedBusinessEvent event) {
final Loan loan = event.get();
if (configurationReadPlatformService.retrieveGlobalConfiguration(ASSET_EXTERNALIZATION_OF_NON_ACTIVE_LOANS).isEnabled()
&& (loan.isClosed() || loan.getStatus().isOverpaid())) {
loanAccountOwnerTransferService.handleLoanClosedOrOverpaid(loan);
}
}
}
}
@@ -18,6 +18,11 @@
*/
package org.apache.fineract.investor.service;
import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanStatus.ACTIVE;
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;
import java.lang.reflect.Type;
@@ -45,7 +50,6 @@ import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.investor.data.ExternalTransferRequestParameters;
import org.apache.fineract.investor.data.ExternalTransferStatus;
import org.apache.fineract.investor.data.ExternalTransferSubStatus;
@@ -65,15 +69,13 @@ 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> NON_CLOSED_LOAN_STATUSES = List.of(LoanStatus.SUBMITTED_AND_PENDING_APPROVAL, LoanStatus.APPROVED,
LoanStatus.ACTIVE, LoanStatus.TRANSFER_IN_PROGRESS, LoanStatus.TRANSFER_ON_HOLD);
private static final List<LoanStatus> ACTIVE_LOAN_STATUSES = List.of(ACTIVE, TRANSFER_IN_PROGRESS, TRANSFER_ON_HOLD);
private static final List<ExternalTransferStatus> BUYBACK_READY_STATUSES = List.of(ExternalTransferStatus.PENDING,
ExternalTransferStatus.ACTIVE);
private final ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository;
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
private final FromJsonHelper fromApiJsonHelper;
private final LoanRepository loanRepository;
private final BusinessEventNotifierService businessEventNotifierService;
@Override
@Transactional
@@ -141,7 +143,7 @@ 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 (ExternalTransferStatus.PENDING.equals(effectiveTransfers.get(0).getStatus())) {
if (PENDING.equals(effectiveTransfers.get(0).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())) {
@@ -190,8 +192,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
} 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"));
} else if (selectedTransfer.getStatus() != ExternalTransferStatus.PENDING
&& selectedTransfer.getStatus() != ExternalTransferStatus.BUYBACK) {
} 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");
}
@@ -256,7 +257,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
}
private void validateLoanStatus(LoanIdAndExternalIdAndStatus entity) {
if (!NON_CLOSED_LOAN_STATUSES.contains(LoanStatus.fromInt(entity.getLoanStatus()))) {
if (!ACTIVE_LOAN_STATUSES.contains(LoanStatus.fromInt(entity.getLoanStatus()))) {
throw new ExternalAssetOwnerInitiateTransferException("Loan is not in active status");
}
}
@@ -268,7 +269,7 @@ public class ExternalAssetOwnersWriteServiceImpl implements ExternalAssetOwnersW
ExternalAssetOwner owner = getOwner(json);
externalAssetOwnerTransfer.setOwner(owner);
externalAssetOwnerTransfer.setExternalId(getTransferExternalIdFromJson(json));
externalAssetOwnerTransfer.setStatus(ExternalTransferStatus.PENDING);
externalAssetOwnerTransfer.setStatus(PENDING);
externalAssetOwnerTransfer.setPurchasePriceRatio(getPurchasePriceRatioFromJson(json));
externalAssetOwnerTransfer.setSettlementDate(getSettlementDateFromJson(json));
externalAssetOwnerTransfer.setEffectiveDateFrom(effectiveFrom);
@@ -0,0 +1,26 @@
/**
* 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 org.apache.fineract.portfolio.loanaccount.domain.Loan;
public interface LoanAccountOwnerTransferService {
void handleLoanClosedOrOverpaid(Loan loan);
}
@@ -0,0 +1,208 @@
/**
* 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.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate;
import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK;
import static org.apache.fineract.investor.data.ExternalTransferStatus.CANCELLED;
import static org.apache.fineract.investor.data.ExternalTransferStatus.DECLINED;
import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING;
import static org.apache.fineract.investor.data.ExternalTransferSubStatus.BALANCE_NEGATIVE;
import static org.apache.fineract.investor.data.ExternalTransferSubStatus.BALANCE_ZERO;
import static org.apache.fineract.investor.data.ExternalTransferSubStatus.SAMEDAY_TRANSFERS;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccountSnapshotBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.interoperation.util.MathUtil;
import org.apache.fineract.investor.data.ExternalTransferStatus;
import org.apache.fineract.investor.data.ExternalTransferSubStatus;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferDetails;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMappingRepository;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository;
import org.apache.fineract.investor.domain.LoanOwnershipTransferBusinessEvent;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor
@Transactional
@Slf4j
public class LoanAccountOwnerTransferServiceImpl implements LoanAccountOwnerTransferService {
public static final LocalDate FUTURE_DATE_9999_12_31 = LocalDate.of(9999, 12, 31);
private final ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository;
private final ExternalAssetOwnerTransferLoanMappingRepository externalAssetOwnerTransferLoanMappingRepository;
private final AccountingService accountingService;
private final BusinessEventNotifierService businessEventNotifierService;
@Override
public void handleLoanClosedOrOverpaid(Loan loan) {
Long loanId = loan.getId();
List<ExternalAssetOwnerTransfer> transferDataList = findAllPendingOrBuybackTransfers(loanId);
if (transferDataList.size() == 2) {
ExternalTransferSubStatus subStatus;
if (isSameDayTransfers(transferDataList)) {
subStatus = SAMEDAY_TRANSFERS;
} else {
subStatus = isBiggerThanZero(loan.getTotalOverpaid()) ? BALANCE_NEGATIVE : BALANCE_ZERO;
}
cancelPendingSaleAndBuybackTransfer(loan, transferDataList, subStatus);
} else if (transferDataList.size() == 1) {
ExternalAssetOwnerTransfer transfer = transferDataList.get(0);
if (PENDING.equals(transfer.getStatus())) {
declinePendingSaleTransfer(loan, transfer);
} else if (BUYBACK.equals(transfer.getStatus())) {
executePendingBuybackTransfer(loan, transfer);
}
}
}
private void cancelPendingSaleAndBuybackTransfer(Loan loan, List<ExternalAssetOwnerTransfer> transferDataList,
ExternalTransferSubStatus subStatus) {
ExternalAssetOwnerTransfer pendingSaleTransfer = transferDataList.get(0);
updatePendingTransfer(pendingSaleTransfer);
ExternalAssetOwnerTransfer cancelledSaleTransfer = createCancelledTransfer(pendingSaleTransfer, subStatus);
ExternalAssetOwnerTransfer pendingBuybackTransfer = transferDataList.get(1);
updatePendingTransfer(pendingBuybackTransfer);
ExternalAssetOwnerTransfer cancelledBuybackTransfer = createCancelledTransfer(pendingBuybackTransfer, subStatus);
businessEventNotifierService.notifyPostBusinessEvent(new LoanOwnershipTransferBusinessEvent(cancelledSaleTransfer, loan));
businessEventNotifierService.notifyPostBusinessEvent(new LoanOwnershipTransferBusinessEvent(cancelledBuybackTransfer, loan));
}
private void declinePendingSaleTransfer(Loan loan, ExternalAssetOwnerTransfer pendingSaleTransfer) {
ExternalAssetOwnerTransfer declinedSaleTransfer = createDeclinedTransfer(pendingSaleTransfer, loan);
updatePendingTransfer(pendingSaleTransfer);
businessEventNotifierService.notifyPostBusinessEvent(new LoanOwnershipTransferBusinessEvent(declinedSaleTransfer, loan));
}
private void executePendingBuybackTransfer(final Loan loan, ExternalAssetOwnerTransfer buybackTransfer) {
ExternalAssetOwnerTransfer activeTransfer = findActiveTransfer(loan, buybackTransfer);
updateActiveTransfer(activeTransfer);
buybackTransfer = updatePendingBuybackTransfer(loan, buybackTransfer);
externalAssetOwnerTransferLoanMappingRepository.deleteByLoanIdAndOwnerTransfer(loan.getId(), activeTransfer);
accountingService.createJournalEntriesForBuybackAssetTransfer(loan, buybackTransfer);
businessEventNotifierService.notifyPostBusinessEvent(new LoanOwnershipTransferBusinessEvent(buybackTransfer, loan));
businessEventNotifierService.notifyPostBusinessEvent(new LoanAccountSnapshotBusinessEvent(loan));
}
private ExternalAssetOwnerTransfer createCancelledTransfer(ExternalAssetOwnerTransfer pendingTransfer,
ExternalTransferSubStatus subStatus) {
ExternalAssetOwnerTransfer cancelledTransfer = new ExternalAssetOwnerTransfer();
cancelledTransfer.setOwner(pendingTransfer.getOwner());
cancelledTransfer.setExternalId(pendingTransfer.getExternalId());
cancelledTransfer.setStatus(CANCELLED);
cancelledTransfer.setSubStatus(subStatus);
cancelledTransfer.setSettlementDate(pendingTransfer.getSettlementDate());
cancelledTransfer.setLoanId(pendingTransfer.getLoanId());
cancelledTransfer.setExternalLoanId(pendingTransfer.getExternalLoanId());
cancelledTransfer.setPurchasePriceRatio(pendingTransfer.getPurchasePriceRatio());
cancelledTransfer.setEffectiveDateFrom(getBusinessLocalDate());
cancelledTransfer.setEffectiveDateTo(getBusinessLocalDate());
return externalAssetOwnerTransferRepository.save(cancelledTransfer);
}
private ExternalAssetOwnerTransfer createDeclinedTransfer(ExternalAssetOwnerTransfer pendingSaleTransfer, Loan loan) {
ExternalAssetOwnerTransfer declinedTransfer = new ExternalAssetOwnerTransfer();
declinedTransfer.setOwner(pendingSaleTransfer.getOwner());
declinedTransfer.setExternalId(pendingSaleTransfer.getExternalId());
declinedTransfer.setStatus(DECLINED);
declinedTransfer.setSubStatus(isBiggerThanZero(loan.getTotalOverpaid()) ? BALANCE_NEGATIVE : BALANCE_ZERO);
declinedTransfer.setSettlementDate(pendingSaleTransfer.getSettlementDate());
declinedTransfer.setLoanId(pendingSaleTransfer.getLoanId());
declinedTransfer.setExternalLoanId(pendingSaleTransfer.getExternalLoanId());
declinedTransfer.setPurchasePriceRatio(pendingSaleTransfer.getPurchasePriceRatio());
declinedTransfer.setEffectiveDateFrom(getBusinessLocalDate());
declinedTransfer.setEffectiveDateTo(getBusinessLocalDate());
return externalAssetOwnerTransferRepository.save(declinedTransfer);
}
private void updatePendingTransfer(ExternalAssetOwnerTransfer pendingTransfer) {
pendingTransfer.setEffectiveDateTo(getBusinessLocalDate());
externalAssetOwnerTransferRepository.save(pendingTransfer);
}
private ExternalAssetOwnerTransfer updatePendingBuybackTransfer(Loan loan, ExternalAssetOwnerTransfer buybackTransfer) {
buybackTransfer.setEffectiveDateTo(getBusinessLocalDate());
buybackTransfer.setExternalAssetOwnerTransferDetails(createAssetOwnerTransferDetails(loan, buybackTransfer));
return externalAssetOwnerTransferRepository.save(buybackTransfer);
}
private void updateActiveTransfer(ExternalAssetOwnerTransfer activeTransfer) {
activeTransfer.setEffectiveDateTo(getBusinessLocalDate());
externalAssetOwnerTransferRepository.save(activeTransfer);
}
private ExternalAssetOwnerTransferDetails createAssetOwnerTransferDetails(Loan loan,
ExternalAssetOwnerTransfer externalAssetOwnerTransfer) {
ExternalAssetOwnerTransferDetails details = new ExternalAssetOwnerTransferDetails();
details.setExternalAssetOwnerTransfer(externalAssetOwnerTransfer);
details.setTotalOutstanding(Objects.requireNonNullElse(loan.getLoanSummary().getTotalOutstanding(), BigDecimal.ZERO));
details.setTotalPrincipalOutstanding(
Objects.requireNonNullElse(loan.getLoanSummary().getTotalPrincipalOutstanding(), BigDecimal.ZERO));
details.setTotalInterestOutstanding(
Objects.requireNonNullElse(loan.getLoanSummary().getTotalInterestOutstanding(), BigDecimal.ZERO));
details.setTotalFeeChargesOutstanding(
Objects.requireNonNullElse(loan.getLoanSummary().getTotalFeeChargesOutstanding(), BigDecimal.ZERO));
details.setTotalPenaltyChargesOutstanding(
Objects.requireNonNullElse(loan.getLoanSummary().getTotalPenaltyChargesOutstanding(), BigDecimal.ZERO));
details.setTotalOverpaid(Objects.requireNonNullElse(loan.getTotalOverpaid(), BigDecimal.ZERO));
return details;
}
private ExternalAssetOwnerTransfer findActiveTransfer(Loan loan, ExternalAssetOwnerTransfer buybackTransfer) {
return externalAssetOwnerTransferRepository
.findOne((root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loan.getId()),
criteriaBuilder.equal(root.get("owner"), buybackTransfer.getOwner()),
criteriaBuilder.equal(root.get("status"), ExternalTransferStatus.ACTIVE),
criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31)))
.orElseThrow();
}
private List<ExternalAssetOwnerTransfer> findAllPendingOrBuybackTransfers(Long loanId) {
return externalAssetOwnerTransferRepository
.findAll(
(root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.equal(root.get("loanId"), loanId),
root.get("status").in(List.of(PENDING, BUYBACK)),
criteriaBuilder.equal(root.get("effectiveDateTo"), FUTURE_DATE_9999_12_31)),
Sort.by(Sort.Direction.ASC, "id"));
}
private boolean isBiggerThanZero(BigDecimal loanTotalOverpaid) {
return MathUtil.nullToDefault(loanTotalOverpaid, BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0;
}
private static boolean isSameDayTransfers(List<ExternalAssetOwnerTransfer> transferDataList) {
return Objects.equals(transferDataList.get(0).getSettlementDate(), transferDataList.get(1).getSettlementDate());
}
}
@@ -0,0 +1,179 @@
/**
* 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.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE;
import static org.apache.fineract.investor.data.ExternalTransferStatus.BUYBACK;
import static org.apache.fineract.investor.data.ExternalTransferStatus.PENDING;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.data.domain.Sort.Direction.ASC;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccountSnapshotBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMappingRepository;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository;
import org.apache.fineract.investor.domain.LoanOwnershipTransferBusinessEvent;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
@ExtendWith(MockitoExtension.class)
public class LoanAccountOwnerTransferServiceTest {
@Mock
private ExternalAssetOwnerTransferRepository externalAssetOwnerTransferRepository;
@Mock
private ExternalAssetOwnerTransferLoanMappingRepository externalAssetOwnerTransferLoanMappingRepository;
@Mock
private AccountingService accountingService;
@Mock
private BusinessEventNotifierService businessEventNotifierService;
private LoanAccountOwnerTransferService underTest;
private final LocalDate actualDate = LocalDate.now(ZoneId.systemDefault());
@BeforeEach
public void setUp() {
ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BUSINESS_DATE, actualDate)));
underTest = new LoanAccountOwnerTransferServiceImpl(externalAssetOwnerTransferRepository,
externalAssetOwnerTransferLoanMappingRepository, accountingService, businessEventNotifierService);
}
@Test
public void verifyWhenCancelPendingSaleAndBuybackTransferThenBusinessEventsAreSent() {
// given
final Loan loanForProcessing = Mockito.mock(Loan.class);
when(loanForProcessing.getId()).thenReturn(1L);
ExternalAssetOwnerTransfer pendingSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
ExternalAssetOwnerTransfer pendingBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
ExternalAssetOwnerTransfer cancelledSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
ExternalAssetOwnerTransfer cancelledBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
List<ExternalAssetOwnerTransfer> response = List.of(pendingSaleTransfer, pendingBuybackTransfer);
when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(ASC, "id")))).thenReturn(response);
when(externalAssetOwnerTransferRepository.save(any(ExternalAssetOwnerTransfer.class))).thenReturn(pendingSaleTransfer)
.thenReturn(cancelledSaleTransfer).thenReturn(pendingBuybackTransfer).thenReturn(cancelledBuybackTransfer);
// when
underTest.handleLoanClosedOrOverpaid(loanForProcessing);
// then
ArgumentCaptor<BusinessEvent<?>> businessEventArgumentCaptor = verifyBusinessEvents(2);
verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, cancelledSaleTransfer);
verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing, cancelledBuybackTransfer);
}
@Test
public void verifyWhenDeclinePendingSaleTransferThenBusinessEventIsSent() {
// given
final Loan loanForProcessing = Mockito.mock(Loan.class);
when(loanForProcessing.getId()).thenReturn(1L);
ExternalAssetOwnerTransfer pendingSaleTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
when(pendingSaleTransfer.getStatus()).thenReturn(PENDING);
ExternalAssetOwnerTransfer declineTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
List<ExternalAssetOwnerTransfer> response = List.of(pendingSaleTransfer);
when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(ASC, "id")))).thenReturn(response);
when(externalAssetOwnerTransferRepository.save(any(ExternalAssetOwnerTransfer.class))).thenReturn(declineTransfer)
.thenReturn(pendingSaleTransfer);
// when
underTest.handleLoanClosedOrOverpaid(loanForProcessing);
// then
ArgumentCaptor<BusinessEvent<?>> businessEventArgumentCaptor = verifyBusinessEvents(1);
verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, declineTransfer);
}
@Test
public void verifyWhenExecutePendingBuybackTransferThenBusinessEventIsSent() {
// given
final Loan loanForProcessing = Mockito.mock(Loan.class);
when(loanForProcessing.getId()).thenReturn(1L);
LoanSummary loanSummary = Mockito.mock(LoanSummary.class);
when(loanForProcessing.getLoanSummary()).thenReturn(loanSummary);
ExternalAssetOwnerTransfer pendingBuybackTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
when(pendingBuybackTransfer.getStatus()).thenReturn(BUYBACK);
ExternalAssetOwnerTransfer activeTransfer = Mockito.mock(ExternalAssetOwnerTransfer.class);
List<ExternalAssetOwnerTransfer> response = List.of(pendingBuybackTransfer);
when(externalAssetOwnerTransferRepository.findAll(any(Specification.class), eq(Sort.by(ASC, "id")))).thenReturn(response);
when(externalAssetOwnerTransferRepository.findOne(any(Specification.class))).thenReturn(Optional.of(activeTransfer));
when(externalAssetOwnerTransferRepository.save(any(ExternalAssetOwnerTransfer.class))).thenReturn(activeTransfer)
.thenReturn(pendingBuybackTransfer);
// when
underTest.handleLoanClosedOrOverpaid(loanForProcessing);
// then
ArgumentCaptor<BusinessEvent<?>> businessEventArgumentCaptor = verifyBusinessEvents(2);
verifyLoanTransferBusinessEvent(businessEventArgumentCaptor, 0, loanForProcessing, pendingBuybackTransfer);
verifyLoanAccountSnapshotBusinessEvent(businessEventArgumentCaptor, 1, loanForProcessing);
}
@NotNull
private ArgumentCaptor<BusinessEvent<?>> verifyBusinessEvents(int expectedBusinessEvents) {
@SuppressWarnings("unchecked")
ArgumentCaptor<BusinessEvent<?>> businessEventArgumentCaptor = ArgumentCaptor.forClass(BusinessEvent.class);
verify(businessEventNotifierService, times(expectedBusinessEvents)).notifyPostBusinessEvent(businessEventArgumentCaptor.capture());
return businessEventArgumentCaptor;
}
private void verifyLoanTransferBusinessEvent(ArgumentCaptor<BusinessEvent<?>> businessEventArgumentCaptor, int index, Loan expectedLoan,
ExternalAssetOwnerTransfer expectedAssetOwnerTransfer) {
assertTrue(businessEventArgumentCaptor.getAllValues().get(index) instanceof LoanOwnershipTransferBusinessEvent);
assertEquals(expectedLoan, ((LoanOwnershipTransferBusinessEvent) businessEventArgumentCaptor.getAllValues().get(index)).getLoan());
assertEquals(expectedAssetOwnerTransfer,
((LoanOwnershipTransferBusinessEvent) businessEventArgumentCaptor.getAllValues().get(index)).get());
}
private void verifyLoanAccountSnapshotBusinessEvent(ArgumentCaptor<BusinessEvent<?>> businessEventArgumentCaptor, int index,
Loan expectedLoan) {
assertTrue(businessEventArgumentCaptor.getAllValues().get(index) instanceof LoanAccountSnapshotBusinessEvent);
assertEquals(expectedLoan, ((LoanAccountSnapshotBusinessEvent) businessEventArgumentCaptor.getAllValues().get(index)).get());
}
}
@@ -81,7 +81,6 @@ public interface LoanRepository extends JpaRepository<Loan, Long>, JpaSpecificat
String FIND_BY_ACCOUNT_NUMBER = "select loan from Loan loan where loan.accountNumber = :accountNumber";
String FIND_LOAN_ID_AND_EXTERNAL_ID_AND_STATUS = "select new org.apache.fineract.cob.data.LoanIdAndExternalIdAndStatus(loan.id, loan.externalId, loan.loanStatus) from Loan loan where loan.id = :loanId";
String EXISTS_NON_CLOSED_BY_EXTERNAL_LOAN_ID = "select case when (count (loan) > 0) then 'true' else 'false' end from Loan loan where loan.externalId = :externalLoanId and loan.loanStatus in (100,200,300,303,304)";
String FIND_ID_BY_EXTERNAL_ID = "SELECT loan.id FROM Loan loan WHERE loan.externalId = :externalId";
@@ -135,4 +135,5 @@
<include file="parts/0113_transaction_summary_with_asset_owner_report_sql_fix.xml" relativeToChangelogFile="true" />
<include file="parts/0114_create_cob_indices.xml" relativeToChangelogFile="true" />
<include file="parts/0115_create_index_from_loan_transaction_id.xml" relativeToChangelogFile="true" />
<include file="parts/0116_add_configuration_asset_externalization_of_non_active_loans.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,41 @@
<?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 http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1" context="postgresql">
<sql>
SELECT SETVAL('c_configuration_id_seq', COALESCE(MAX(id), 0)+1, false ) FROM c_configuration;
</sql>
</changeSet>
<changeSet author="fineract" id="2">
<insert tableName="c_configuration">
<column name="name" value="asset-externalization-of-non-active-loans"/>
<column name="value"/>
<column name="date_value"/>
<column name="string_value"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="If enabled: when a loan state is changed to non-active -> pending transfers will be handled"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -119,9 +119,8 @@ public class GlobalConfigurationHelper {
ArrayList<HashMap> expectedGlobalConfigurations = getAllDefaultGlobalConfigurations();
ArrayList<HashMap> actualGlobalConfigurations = getAllGlobalConfigurations(requestSpec, responseSpec);
// There are currently 50 global configurations.
Assertions.assertEquals(51, expectedGlobalConfigurations.size());
Assertions.assertEquals(51, actualGlobalConfigurations.size());
Assertions.assertEquals(52, expectedGlobalConfigurations.size());
Assertions.assertEquals(52, actualGlobalConfigurations.size());
for (int i = 0; i < expectedGlobalConfigurations.size(); i++) {
@@ -566,6 +565,15 @@ public class GlobalConfigurationHelper {
accrualForChargeDate.put("string_value", "due-date");
defaults.add(accrualForChargeDate);
HashMap<String, Object> assetExternalizationOfNonActiveLoans = new HashMap<>();
assetExternalizationOfNonActiveLoans.put("id", 57);
assetExternalizationOfNonActiveLoans.put("name", "asset-externalization-of-non-active-loans");
assetExternalizationOfNonActiveLoans.put("value", 0);
assetExternalizationOfNonActiveLoans.put("enabled", true);
assetExternalizationOfNonActiveLoans.put("trapDoor", false);
assetExternalizationOfNonActiveLoans.put("string_value", "due-date");
defaults.add(assetExternalizationOfNonActiveLoans);
return defaults;
}
@@ -21,7 +21,10 @@ 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.BUYBACK;
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.apache.fineract.client.models.ExternalTransferData.SubStatusEnum.SAMEDAY_TRANSFERS;
import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -496,6 +499,118 @@ public class InitiateExternalAssetOwnerTransferTest {
}
}
@Test
public void saleIsDeclinedWhenLoanIsCancelled() {
try {
GlobalConfigurationHelper.manageConfigurations(REQUEST_SPEC, RESPONSE_SPEC,
GlobalConfigurationHelper.ENABLE_AUTOGENERATED_EXTERNAL_ID, true);
setInitialBusinessDate("2020-03-02");
Integer clientID = createClient();
Integer loanID = createLoanForClient(clientID);
PostInitiateTransferResponse saleTransferResponse = createSaleTransfer(loanID, "2020-03-06");
updateBusinessDateAndExecuteCOBJob("2020-03-04");
LOAN_TRANSACTION_HELPER.writeOffLoan("04 March 2020", loanID);
getAndValidateExternalAssetOwnerTransferByLoan(loanID,
ExpectedExternalTransferData.expected(PENDING, saleTransferResponse.getResourceExternalId(), "2020-03-06", "2020-03-02",
"2020-03-04"),
ExpectedExternalTransferData.expected(DECLINED, saleTransferResponse.getResourceExternalId(), "2020-03-06",
"2020-03-04", "2020-03-04", BALANCE_ZERO));
} finally {
cleanUpAndRestoreBusinessDate();
}
}
@Test
public void buybackIsExecutedWhenLoanIsCancelled() {
try {
GlobalConfigurationHelper.manageConfigurations(REQUEST_SPEC, RESPONSE_SPEC,
GlobalConfigurationHelper.ENABLE_AUTOGENERATED_EXTERNAL_ID, true);
setInitialBusinessDate("2020-03-02");
Integer clientID = createClient();
Integer loanID = createLoanForClient(clientID);
PostInitiateTransferResponse saleTransferResponse = createSaleTransfer(loanID, "2020-03-04");
updateBusinessDateAndExecuteCOBJob("2020-03-05");
PostInitiateTransferResponse buybackTransferResponse = createBuybackTransfer(loanID, "2020-03-06");
LOAN_TRANSACTION_HELPER.writeOffLoan("04 March 2020", loanID);
getAndValidateExternalAssetOwnerTransferByLoan(loanID,
ExpectedExternalTransferData.expected(PENDING, saleTransferResponse.getResourceExternalId(), "2020-03-04", "2020-03-02",
"2020-03-04"),
ExpectedExternalTransferData.expected(ACTIVE, saleTransferResponse.getResourceExternalId(), "2020-03-04", "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"),
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"),
new BigDecimal("0.000000")));
getAndValidateThereIsNoActiveMapping(saleTransferResponse.getResourceExternalId());
} finally {
cleanUpAndRestoreBusinessDate();
}
}
@Test
public void buybackAndSaleIsCancelledWhenLoanIsCancelled() {
try {
GlobalConfigurationHelper.manageConfigurations(REQUEST_SPEC, RESPONSE_SPEC,
GlobalConfigurationHelper.ENABLE_AUTOGENERATED_EXTERNAL_ID, true);
setInitialBusinessDate("2020-03-02");
Integer clientID = createClient();
Integer loanID = createLoanForClient(clientID);
PostInitiateTransferResponse saleTransferResponse = createSaleTransfer(loanID, "2020-03-04");
PostInitiateTransferResponse buybackTransferResponse = createBuybackTransfer(loanID, "2020-03-06");
LOAN_TRANSACTION_HELPER.writeOffLoan("02 March 2020", loanID);
getAndValidateExternalAssetOwnerTransferByLoan(loanID,
ExpectedExternalTransferData.expected(PENDING, saleTransferResponse.getResourceExternalId(), "2020-03-04", "2020-03-02",
"2020-03-02"),
ExpectedExternalTransferData.expected(BUYBACK, buybackTransferResponse.getResourceExternalId(), "2020-03-06",
"2020-03-02", "2020-03-02"),
ExpectedExternalTransferData.expected(CANCELLED, buybackTransferResponse.getResourceExternalId(), "2020-03-06",
"2020-03-02", "2020-03-02", BALANCE_ZERO),
ExpectedExternalTransferData.expected(CANCELLED, saleTransferResponse.getResourceExternalId(), "2020-03-04",
"2020-03-02", "2020-03-02", BALANCE_ZERO));
} finally {
cleanUpAndRestoreBusinessDate();
}
}
@Test
public void sameDayBuybackAndSaleIsCancelledWhenLoanIsCancelled() {
try {
GlobalConfigurationHelper.manageConfigurations(REQUEST_SPEC, RESPONSE_SPEC,
GlobalConfigurationHelper.ENABLE_AUTOGENERATED_EXTERNAL_ID, true);
setInitialBusinessDate("2020-03-02");
Integer clientID = createClient();
Integer loanID = createLoanForClient(clientID);
PostInitiateTransferResponse saleTransferResponse = createSaleTransfer(loanID, "2020-03-03");
PostInitiateTransferResponse buybackTransferResponse = createBuybackTransfer(loanID, "2020-03-03");
LOAN_TRANSACTION_HELPER.writeOffLoan("02 March 2020", loanID);
getAndValidateExternalAssetOwnerTransferByLoan(loanID,
ExpectedExternalTransferData.expected(PENDING, saleTransferResponse.getResourceExternalId(), "2020-03-03", "2020-03-02",
"2020-03-02"),
ExpectedExternalTransferData.expected(BUYBACK, buybackTransferResponse.getResourceExternalId(), "2020-03-03",
"2020-03-02", "2020-03-02"),
ExpectedExternalTransferData.expected(CANCELLED, buybackTransferResponse.getResourceExternalId(), "2020-03-03",
"2020-03-02", "2020-03-02", SAMEDAY_TRANSFERS),
ExpectedExternalTransferData.expected(CANCELLED, saleTransferResponse.getResourceExternalId(), "2020-03-03",
"2020-03-02", "2020-03-02", SAMEDAY_TRANSFERS));
} finally {
cleanUpAndRestoreBusinessDate();
}
}
@Test
public void saleAndBuybackOnTheSameDay() {
try {
@@ -592,8 +707,11 @@ public class InitiateExternalAssetOwnerTransferTest {
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, () -> createBuybackTransfer(1, null));
assertTrue(exception.getMessage().contains("The parameter `settlementDate` is mandatory."));
CallFailedRuntimeException exception2 = assertThrows(CallFailedRuntimeException.class,
() -> createBuybackTransfer(1, "1970-01-01"));
CallFailedRuntimeException exception2 = assertThrows(CallFailedRuntimeException.class, () -> {
Integer clientID = createClient();
Integer loanID = createLoanForClient(clientID);
createBuybackTransfer(loanID, "1970-01-01");
});
assertTrue(exception2.getMessage().contains("Settlement date cannot be in the past"));
CallFailedRuntimeException exception3 = assertThrows(CallFailedRuntimeException.class, () -> {
@@ -839,6 +957,9 @@ public class InitiateExternalAssetOwnerTransferTest {
assertEquals(expected.totalFeeOutstanding, etd.getDetails().getTotalFeeChargesOutstanding());
assertEquals(expected.totalOverpaid, etd.getDetails().getTotalOverpaid());
}
if (expected.subStatus != null) {
assertEquals(expected.subStatus, etd.getSubStatus());
}
}
}
@@ -919,6 +1040,7 @@ public class InitiateExternalAssetOwnerTransferTest {
private final String effectiveFrom;
private final String effectiveTo;
private final ExternalTransferData.SubStatusEnum subStatus;
private final boolean detailsExpected;
private final BigDecimal totalOutstanding;
private final BigDecimal totalPrincipalOutstanding;
@@ -931,11 +1053,22 @@ public class InitiateExternalAssetOwnerTransferTest {
String settlementDate, String effectiveFrom, String effectiveTo, boolean detailsExpected, BigDecimal totalOutstanding,
BigDecimal totalPrincipalOutstanding, BigDecimal totalInterestOutstanding, BigDecimal totalPenaltyOutstanding,
BigDecimal totalFeeOutstanding, BigDecimal totalOverpaid) {
return new ExpectedExternalTransferData(status, transferExternalId, settlementDate, effectiveFrom, effectiveTo, detailsExpected,
totalOutstanding, totalPrincipalOutstanding, totalInterestOutstanding, totalPenaltyOutstanding, totalFeeOutstanding,
totalOverpaid);
return new ExpectedExternalTransferData(status, transferExternalId, settlementDate, effectiveFrom, effectiveTo, null,
detailsExpected, totalOutstanding, totalPrincipalOutstanding, totalInterestOutstanding, totalPenaltyOutstanding,
totalFeeOutstanding, totalOverpaid);
}
static ExpectedExternalTransferData expected(ExternalTransferData.StatusEnum status, String transferExternalId,
String settlementDate, String effectiveFrom, String effectiveTo) {
return new ExpectedExternalTransferData(status, transferExternalId, settlementDate, effectiveFrom, effectiveTo, null, false,
null, null, null, null, null, null);
}
static ExpectedExternalTransferData expected(ExternalTransferData.StatusEnum status, String transferExternalId,
String settlementDate, String effectiveFrom, String effectiveTo, ExternalTransferData.SubStatusEnum subStatus) {
return new ExpectedExternalTransferData(status, transferExternalId, settlementDate, effectiveFrom, effectiveTo, subStatus,
false, null, null, null, null, null, null);
}
}
@RequiredArgsConstructor()