FINERACT-2107: Interest Refund - Allocation, Business Event & Full Repayment

This commit is contained in:
Soma Sörös
2024-10-21 17:36:20 +02:00
committed by Adam Saghy
parent 84d606552d
commit 5722891477
18 changed files with 969 additions and 77 deletions
@@ -0,0 +1,35 @@
/**
* 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.infrastructure.event.business.domain.loan.transaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public class LoanTransactionInterestRefundPostBusinessEvent extends LoanTransactionBusinessEvent {
private static final String TYPE = "LoanTransactionInterestRefundPostBusinessEvent";
public LoanTransactionInterestRefundPostBusinessEvent(LoanTransaction value) {
super(value);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -0,0 +1,36 @@
/**
* 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.infrastructure.event.business.domain.loan.transaction;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanBusinessEvent;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
public class LoanTransactionInterestRefundPreBusinessEvent extends LoanBusinessEvent {
private static final String TYPE = "LoanTransactionInterestRefundPreBusinessEvent";
public LoanTransactionInterestRefundPreBusinessEvent(Loan value) {
super(value);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -1718,6 +1718,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public List<Long> findExistingTransactionIds() {
return getLoanTransactions().stream() //
.filter(loanTransaction -> loanTransaction.getId() != null) //
.map(LoanTransaction::getId) //
.collect(Collectors.toList());
}
@@ -1725,6 +1726,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public List<Long> findExistingReversedTransactionIds() {
return getLoanTransactions().stream() //
.filter(LoanTransaction::isReversed) //
.filter(loanTransaction -> loanTransaction.getId() != null) //
.map(LoanTransaction::getId) //
.collect(Collectors.toList());
}
@@ -2256,7 +2258,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
private void validateRepaymentTypeAccountStatus(LoanTransaction repaymentTransaction, LoanEvent event) {
if (repaymentTransaction.isGoodwillCredit() || repaymentTransaction.isInterestPaymentWaiver()
|| repaymentTransaction.isMerchantIssuedRefund() || repaymentTransaction.isPayoutRefund()
|| repaymentTransaction.isChargeRefund() || repaymentTransaction.isRepayment() || repaymentTransaction.isDownPayment()) {
|| repaymentTransaction.isChargeRefund() || repaymentTransaction.isRepayment() || repaymentTransaction.isDownPayment()
|| repaymentTransaction.isInterestRefund()) {
if (!(isOpen() || isClosedObligationsMet() || isOverPaid())) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
@@ -189,6 +189,11 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
chargeRefundChargeType);
}
public static LoanTransaction interestRefund(final Loan loan, final BigDecimal amount, final LocalDate date,
final ExternalId externalId) {
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.INTEREST_REFUND, null, amount, date, externalId);
}
public static LoanTransaction chargeAdjustment(final Loan loan, final BigDecimal amount, final LocalDate transactionDate,
final ExternalId externalId, PaymentDetail paymentDetail) {
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.CHARGE_ADJUSTMENT, paymentDetail, amount, transactionDate,
@@ -596,7 +601,7 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
public boolean isRepaymentLikeType() {
return isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund()
|| isChargeAdjustment() || isDownPayment() || isInterestPaymentWaiver();
|| isChargeAdjustment() || isDownPayment() || isInterestPaymentWaiver() || isInterestRefund();
}
public boolean isTypeAllowedForChargeback() {
@@ -1118,6 +1123,10 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
return MathUtil.isGreaterThanZero(overPaymentPortion);
}
public boolean isInterestRefund() {
return getTypeOf().isInterestRefund();
}
// TODO missing hashCode(), equals(Object obj), but probably OK as long as
// this is never stored in a Collection.
}
@@ -0,0 +1,32 @@
/**
* 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.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
public interface InterestRefundService {
boolean canHandle(Loan loan);
BigDecimal calculateInterestRefundAmount(Long loanId, BigDecimal relatedRefundTransactionAmount,
LocalDate relatedRefundTransactionDate);
}
@@ -0,0 +1,35 @@
/**
* 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.portfolio.loanaccount.service;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class InterestRefundServiceDelegate {
private final List<InterestRefundService> interestRefundService;
public InterestRefundService lookupInterestRefundService(final Loan loan) {
return interestRefundService.stream().filter(iRS -> iRS.canHandle(loan)).findFirst().orElse(null);
}
}
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType;
public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy";
public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY_NAME = "Advanced payment allocation strategy";
public final EMICalculator emiCalculator;
@@ -106,7 +107,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
@Override
public String getName() {
return "Advanced payment allocation strategy";
return ADVANCED_PAYMENT_ALLOCATION_STRATEGY_NAME;
}
@Override
@@ -232,8 +233,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
case CHARGEBACK -> handleChargeback(loanTransaction, ctx);
case CREDIT_BALANCE_REFUND ->
handleCreditBalanceRefund(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getOverpaymentHolder());
case REPAYMENT, MERCHANT_ISSUED_REFUND, PAYOUT_REFUND, GOODWILL_CREDIT, CHARGE_REFUND, CHARGE_ADJUSTMENT, DOWN_PAYMENT,
WAIVE_INTEREST, RECOVERY_REPAYMENT, INTEREST_PAYMENT_WAIVER ->
case INTEREST_REFUND, REPAYMENT, MERCHANT_ISSUED_REFUND, PAYOUT_REFUND, GOODWILL_CREDIT, CHARGE_REFUND, CHARGE_ADJUSTMENT,
DOWN_PAYMENT, WAIVE_INTEREST, RECOVERY_REPAYMENT, INTEREST_PAYMENT_WAIVER ->
handleRepayment(loanTransaction, ctx);
case CHARGE_OFF -> handleChargeOff(loanTransaction, ctx);
case CHARGE_PAYMENT -> handleChargePayment(loanTransaction, ctx);
@@ -55,6 +55,8 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionGoodwillCreditPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionInterestPaymentWaiverPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionInterestPaymentWaiverPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionInterestRefundPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionInterestRefundPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionMakeRepaymentPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionMakeRepaymentPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionMerchantIssuedRefundPostBusinessEvent;
@@ -89,11 +91,14 @@ import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.LoanRefundRequestData;
import org.apache.fineract.portfolio.loanaccount.data.LoanScheduleDelinquencyData;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.service.InterestRefundService;
import org.apache.fineract.portfolio.loanaccount.service.InterestRefundServiceDelegate;
import org.apache.fineract.portfolio.loanaccount.service.LoanAccrualTransactionBusinessEventService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAccrualsProcessingService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService;
import org.apache.fineract.portfolio.loanaccount.service.ReplayedTransactionBusinessEventService;
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
@@ -133,6 +138,7 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
private final DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper;
private final DelinquencyReadPlatformService delinquencyReadPlatformService;
private final LoanAccrualsProcessingService loanAccrualsProcessingService;
private final InterestRefundServiceDelegate interestRefundServiceDelegate;
@Transactional
@Override
@@ -159,6 +165,26 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
this.loanCollateralManagementRepository.saveAll(loanCollateralManagementSet);
}
@Transactional
public LoanTransaction createInterestRefundLoanTransaction(Loan loan, final LocalDate transactionDate,
BigDecimal relatedRefundTransactionAmount) {
InterestRefundService interestRefundService = interestRefundServiceDelegate.lookupInterestRefundService(loan);
if (interestRefundService == null) {
return null;
}
BigDecimal interestRefundAmount = interestRefundService.calculateInterestRefundAmount(loan.getId(), relatedRefundTransactionAmount,
transactionDate);
final ExternalId txnExternalId = externalIdFactory.create();
businessEventNotifierService.notifyPreBusinessEvent(new LoanTransactionInterestRefundPreBusinessEvent(loan));
LoanTransaction newInterestRefundTransaction;
newInterestRefundTransaction = LoanTransaction.interestRefund(loan, interestRefundAmount, transactionDate, txnExternalId);
return newInterestRefundTransaction;
}
@Transactional
@Override
public LoanTransaction makeRepayment(final LoanTransactionType repaymentTransactionType, Loan loan, final LocalDate transactionDate,
@@ -199,6 +225,18 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
final ScheduleGeneratorDTO scheduleGeneratorDTO = this.loanUtilService.buildScheduleGeneratorDTO(loan, recalculateFrom,
holidayDetailDto);
boolean shouldCreateInterestRefundTransaction = loan.getLoanProductRelatedDetail().getSupportedInterestRefundTypes().stream()
.map(LoanSupportedInterestRefundTypes::getTransactionType)
.anyMatch(transactionType -> transactionType.equals(repaymentTransactionType));
LoanTransaction newInterestRefundTransaction = null;
if (shouldCreateInterestRefundTransaction) {
newInterestRefundTransaction = createInterestRefundLoanTransaction(loan, transactionDate, transactionAmount);
if (newInterestRefundTransaction != null) {
loan.addLoanTransaction(newInterestRefundTransaction);
}
}
final ChangedTransactionDetail changedTransactionDetail = loan.makeRepayment(newRepaymentTransaction,
defaultLoanLifecycleStateMachine, existingTransactionIds, existingReversedTransactionIds, isRecoveryRepayment,
scheduleGeneratorDTO, isHolidayValidationDone);
@@ -209,6 +247,9 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
}
saveLoanTransactionWithDataIntegrityViolationChecks(newRepaymentTransaction);
if (newInterestRefundTransaction != null) {
saveLoanTransactionWithDataIntegrityViolationChecks(newInterestRefundTransaction);
}
/***
* TODO Vishwas Batch save is giving me a HibernateOptimisticLockingFailureException, looping and saving for the
@@ -243,7 +284,6 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
businessEventNotifierService.notifyPostBusinessEvent(transactionRepaymentEvent);
}
// disable all active standing orders linked to this loan if status
// changes to closed
disableStandingInstructionsLinkedToClosedLoan(loan);
@@ -276,7 +316,10 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
}
}
}
if (shouldCreateInterestRefundTransaction && newInterestRefundTransaction != null) {
businessEventNotifierService
.notifyPostBusinessEvent(new LoanTransactionInterestRefundPostBusinessEvent(newInterestRefundTransaction));
}
return newRepaymentTransaction;
}
@@ -299,6 +342,8 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
repaymentEvent = new LoanTransactionRecoveryPaymentPreBusinessEvent(loan);
} else if (repaymentTransactionType.isDownPayment()) {
repaymentEvent = new LoanTransactionDownPaymentPreBusinessEvent(loan);
} else if (repaymentTransactionType.isInterestRefund()) {
repaymentEvent = new LoanTransactionInterestRefundPreBusinessEvent(loan);
}
return repaymentEvent;
}
@@ -322,6 +367,8 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
repaymentEvent = new LoanTransactionRecoveryPaymentPostBusinessEvent(transaction);
} else if (repaymentTransactionType.isDownPayment()) {
repaymentEvent = new LoanTransactionDownPaymentPostBusinessEvent(transaction);
} else if (repaymentTransactionType.isInterestRefund()) {
repaymentEvent = new LoanTransactionInterestRefundPostBusinessEvent(transaction);
}
return repaymentEvent;
}
@@ -0,0 +1,115 @@
/**
* 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.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.starter.AdvancedPaymentScheduleTransactionProcessorCondition;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.springframework.context.annotation.Conditional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
@Conditional(AdvancedPaymentScheduleTransactionProcessorCondition.class)
@Service
public class ProgressiveLoanInterestRefundServiceImpl implements InterestRefundService {
private final AdvancedPaymentScheduleTransactionProcessor processor;
private final EMICalculator emiCalculator;
private final LoanAssembler loanAssembler;
@Override
public boolean canHandle(Loan loan) {
return loan != null && loan.isInterestBearing() && processor.accept(loan.getTransactionProcessingStrategyCode());
}
private static boolean omitDisbursements(LoanTransaction lt, final AtomicReference<BigDecimal> refundFinal) {
if (lt.getTypeOf().isDisbursement() && refundFinal.get().compareTo(BigDecimal.ZERO) > 0) {
if (lt.getAmount().compareTo(refundFinal.get()) <= 0) {
refundFinal.set(refundFinal.get().subtract(lt.getAmount()));
return false;
}
}
return true;
}
private static LoanTransaction calculateReducedAmountDisbursements(LoanTransaction lt, final AtomicReference<BigDecimal> refundFinal) {
if (lt.getTypeOf().isDisbursement() && refundFinal.get().compareTo(BigDecimal.ZERO) > 0) {
LoanTransaction result = new LoanTransaction(lt.getLoan(), lt.getLoan().getOffice(), lt.getTypeOf().getValue(), lt.getDateOf(),
lt.getAmount().subtract(refundFinal.get()), lt.getPrincipalPortion(), lt.getInterestPortion(),
lt.getFeeChargesPortion(), lt.getPenaltyChargesPortion(),
lt.getOverPaymentPortion(lt.getLoan().getCurrency()).getAmount(), lt.isReversed(), lt.getPaymentDetail(),
lt.getExternalId());
refundFinal.set(BigDecimal.ZERO);
return result;
}
return lt;
}
private BigDecimal totalInterest(final Loan loan, BigDecimal refundAmount, LocalDate relatedRefundTransactionDate) {
final AtomicReference<BigDecimal> refundFinal = new AtomicReference<>(refundAmount);
List<LoanTransaction> transactionsToReprocess = loan.getLoanTransactions().stream().filter(lt -> !lt.isReversed()) //
.filter(lt -> !lt.isAccrual() && !lt.isAccrualActivity()) //
.filter(lt -> omitDisbursements(lt, refundFinal)) //
.map(lt -> calculateReducedAmountDisbursements(lt, refundFinal)).toList();
List<LoanRepaymentScheduleInstallment> installmentsToReprocess = new ArrayList<>(
loan.getRepaymentScheduleInstallments().stream().filter(i -> !i.isReAged() && !i.isAdditional()).toList());
ProgressiveLoanInterestScheduleModel modelAfter = processor.reprocessProgressiveLoanTransactions(loan.getDisbursementDate(),
transactionsToReprocess, loan.getCurrency(), installmentsToReprocess, loan.getActiveCharges()).getRight();
BigDecimal payableInterest = BigDecimal.ZERO;
if (modelAfter != null && loan.getStatus().isActive()) {
LoanRepaymentScheduleInstallment actualInstallment = loan.getRelatedRepaymentScheduleInstallment(relatedRefundTransactionDate);
if (actualInstallment == null) {
actualInstallment = loan.getLastLoanRepaymentScheduleInstallment();
}
payableInterest = emiCalculator.getPayableDetails(modelAfter, actualInstallment.getDueDate(), relatedRefundTransactionDate)
.getPayableInterest().getAmount();
}
BigDecimal paidInterest = installmentsToReprocess.stream().map(i -> i.getInterestPaid(loan.getCurrency())).filter(Objects::nonNull)
.map(Money::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
return payableInterest.add(paidInterest);
}
@Override
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
public BigDecimal calculateInterestRefundAmount(Long loanId, BigDecimal relatedRefundTransactionAmount,
LocalDate relatedRefundTransactionDate) {
Loan loan = loanAssembler.assembleFrom(loanId);
BigDecimal totalInterestBeforeRefund = totalInterest(loan, BigDecimal.ZERO, relatedRefundTransactionDate);
BigDecimal totalInterestAfterRefund = totalInterest(loan, relatedRefundTransactionAmount, relatedRefundTransactionDate);
return totalInterestBeforeRefund.subtract(totalInterestAfterRefund);
}
}
@@ -169,4 +169,5 @@
<include file="parts/0148_transaction_summary_with_asset_owner_report_performance_improvement.xml" relativeToChangelogFile="true" />
<include file="parts/0149_update_global_configuration_names.xml" relativeToChangelogFile="true" />
<include file="parts/0150_transaction_summary_with_asset_owner_report_interest_waiver_interest_refund_added.xml" relativeToChangelogFile="true" />
<include file="parts/0151_interest_refund_business_events.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,35 @@
<?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">
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanTransactionInterestRefundPostBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanTransactionInterestRefundPreBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -104,7 +104,8 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanUndoReAmortizeTransactionBusinessEvent", "LoanReAgeBusinessEvent", "LoanUndoReAgeBusinessEvent",
"LoanReAmortizeBusinessEvent", "LoanUndoReAmortizeBusinessEvent", "LoanTransactionInterestPaymentWaiverPreBusinessEvent",
"LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent",
"LoanTransactionAccrualActivityPreBusinessEvent");
"LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent",
"LoanTransactionInterestRefundPreBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -189,7 +190,8 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanUndoReAmortizeTransactionBusinessEvent", "LoanReAgeBusinessEvent", "LoanUndoReAgeBusinessEvent",
"LoanReAmortizeBusinessEvent", "LoanUndoReAmortizeBusinessEvent", "LoanTransactionInterestPaymentWaiverPreBusinessEvent",
"LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent",
"LoanTransactionAccrualActivityPreBusinessEvent");
"LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent",
"LoanTransactionInterestRefundPreBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -76,7 +76,6 @@ import org.apache.fineract.integrationtests.common.accounting.PeriodicAccrualAcc
import org.apache.fineract.integrationtests.common.charges.ChargesHelper;
import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor;
@@ -4426,7 +4425,7 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(5.0).interestCalculationPeriodType(DAYS).interestRateFrequencyType(YEARS)
.daysInMonthType(DaysInMonthType.DAYS_30.getValue()).daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(5)//
.daysInMonthType(DaysInMonthType.DAYS_30).daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(5)//
.repaymentEvery(1)//
.repaymentFrequencyType(2L)//
.enableDownPayment(true)//
@@ -4488,7 +4487,7 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(12.3).interestCalculationPeriodType(RepaymentFrequencyType.DAYS).interestRateFrequencyType(YEARS)
.daysInMonthType(DaysInMonthType.DAYS_30.getValue()).daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(5)//
.daysInMonthType(DaysInMonthType.DAYS_30).daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(5)//
.repaymentEvery(1)//
.repaymentFrequencyType(2L)//
.enableDownPayment(true)//
@@ -4551,7 +4550,7 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(12.3).interestCalculationPeriodType(RepaymentFrequencyType.DAYS).interestRateFrequencyType(YEARS)
.daysInMonthType(DaysInMonthType.DAYS_30.getValue()).daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(5)//
.daysInMonthType(DaysInMonthType.DAYS_30).daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(5)//
.repaymentEvery(1)//
.repaymentFrequencyType(2L)//
.enableDownPayment(true)//
@@ -4614,7 +4613,7 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(12.3).interestCalculationPeriodType(RepaymentFrequencyType.DAYS).interestRateFrequencyType(YEARS)
.daysInMonthType(DaysInMonthType.ACTUAL.getValue()).daysInYearType(DaysInYearType.DAYS_365).numberOfRepayments(4)//
.daysInMonthType(DaysInMonthType.ACTUAL).daysInYearType(DaysInYearType.DAYS_365).numberOfRepayments(4)//
.repaymentEvery(1)//
.repaymentFrequencyType(2L)//
.allowPartialPeriodInterestCalcualtion(false)//
@@ -4692,8 +4691,8 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(108.0).interestCalculationPeriodType(RepaymentFrequencyType.DAYS)
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.ACTUAL.getValue())
.daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(4)//
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.ACTUAL).daysInYearType(DaysInYearType.DAYS_360)
.numberOfRepayments(4)//
.maxInterestRatePerPeriod((double) 110)//
.repaymentEvery(1)//
.repaymentFrequencyType(1L)//
@@ -4873,7 +4872,7 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
interestRefundTypes.add("MERCHANT_ISSUED_REFUND");
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(12.0).interestCalculationPeriodType(RepaymentFrequencyType.DAYS).interestRateFrequencyType(YEARS)
.daysInMonthType(DaysInMonthType.ACTUAL.getValue()).daysInYearType(DaysInYearType.DAYS_365).numberOfRepayments(4)//
.daysInMonthType(DaysInMonthType.ACTUAL).daysInYearType(DaysInYearType.DAYS_365).numberOfRepayments(4)//
.repaymentEvery(5)//
.repaymentFrequencyType(0L)//
.allowPartialPeriodInterestCalcualtion(false)//
@@ -4935,8 +4934,8 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(interestRatePerPeriod.doubleValue()).interestCalculationPeriodType(RepaymentFrequencyType.DAYS)
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.DAYS_30.getValue())
.daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(6)//
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.DAYS_30).daysInYearType(DaysInYearType.DAYS_360)
.numberOfRepayments(6)//
.repaymentEvery(1)//
.repaymentFrequencyType(2L)//
.enableDownPayment(false)//
@@ -5017,8 +5016,8 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(interestRatePerPeriod.doubleValue()).interestCalculationPeriodType(RepaymentFrequencyType.DAYS)
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.DAYS_30.getValue())
.daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(6)//
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.DAYS_30).daysInYearType(DaysInYearType.DAYS_360)
.numberOfRepayments(6)//
.repaymentEvery(1)//
.repaymentFrequencyType(2L)//
.enableDownPayment(false)//
@@ -5114,7 +5113,7 @@ public class AdvancedPaymentAllocationLoanRepaymentScheduleTest extends BaseLoan
.interestRatePerPeriod(interestRatePerPeriod.doubleValue())//
.interestCalculationPeriodType(RepaymentFrequencyType.DAYS)//
.interestRateFrequencyType(YEARS)//
.daysInMonthType(DaysInMonthType.DAYS_30.getValue())//
.daysInMonthType(DaysInMonthType.DAYS_30)//
.daysInYearType(DaysInYearType.DAYS_360)//
.numberOfRepayments(6)//
.repaymentEvery(1)//
@@ -1281,6 +1281,13 @@ public abstract class BaseLoanIntegrationTest {
public static final Integer DAYS_365 = 365;
}
public static class DaysInMonthType {
public static final Integer INVALID = 0;
public static final Integer ACTUAL = 1;
public static final Integer DAYS_30 = 30;
}
public static class FuturePaymentAllocationRule {
public static final String LAST_INSTALLMENT = "LAST_INSTALLMENT";
@@ -25,9 +25,11 @@ import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -35,6 +37,8 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
@@ -42,6 +46,8 @@ import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostCreateRescheduleLoansRequest;
import org.apache.fineract.client.models.PostCreateRescheduleLoansResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostUpdateRescheduleLoansRequest;
import org.apache.fineract.infrastructure.event.external.service.validation.ExternalEventDTO;
@@ -111,7 +117,7 @@ public class ExternalBusinessEventTest extends BaseLoanIntegrationTest {
loanTransactionHelper.makeLoanRepayment("15 March 2023", 125.0F, loanIdRef.get().intValue());
verifyBusinessEvents(new BusinessEvent("LoanBalanceChangedBusinessEvent", "15 March 2023", 300, 400.0, 291.04));
verifyBusinessEvents(new LoanBusinessEvent("LoanBalanceChangedBusinessEvent", "15 March 2023", 300, 400.0, 291.04));
});
runAt("1 April 2023", () -> {
@@ -123,7 +129,7 @@ public class ExternalBusinessEventTest extends BaseLoanIntegrationTest {
loanTransactionHelper.makeLoanRepayment("15 April 2023", 125.0F, loanIdRef.get().intValue());
verifyBusinessEvents(new BusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 300, 1000.0, 770.06));
verifyBusinessEvents(new LoanBusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 300, 1000.0, 770.06));
deleteAllExternalEvents();
@@ -131,24 +137,57 @@ public class ExternalBusinessEventTest extends BaseLoanIntegrationTest {
.getResourceId();
Assertions.assertNotNull(transactionId);
verifyBusinessEvents(new BusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 700, 1000.0, 0.0));
verifyBusinessEvents(new LoanBusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 700, 1000.0, 0.0));
deleteAllExternalEvents();
loanTransactionHelper.reverseRepayment(loanIdRef.get().intValue(), transactionId.intValue(), "15 April 2023");
verifyBusinessEvents(new BusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 300, 1000.0, 770.06));
verifyBusinessEvents(new LoanBusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 300, 1000.0, 770.06));
deleteAllExternalEvents();
loanTransactionHelper.makeLoanRepayment("15 April 2023", 830.22F, loanIdRef.get().intValue());
verifyBusinessEvents(new BusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 600, 1000.0, 0.0));
verifyBusinessEvents(new LoanBusinessEvent("LoanBalanceChangedBusinessEvent", "15 April 2023", 600, 1000.0, 0.0));
disableLoanBalanceChangedBusinessEvent();
});
}
@Test
public void verifyInterestRefundPostBusinessEventCreatedForMerchantIssuedRefundWithInterestRefund() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
enableLoanInterestRefundPstBusinessEvent(true);
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
deleteAllExternalEvents();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("MerchantIssuedRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
verifyBusinessEvents(new LoanTransactionBusinessEvent("LoanTransactionInterestRefundPostBusinessEvent", "22 January 2021", 5.75,
994.25, 5.75, 0.0, 0.0, 0.0));
});
enableLoanInterestRefundPstBusinessEvent(false);
}
@Test
public void testExternalBusinessEventLoanRescheduledDueAdjustScheduleBusinessEventInterestChange() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
@@ -171,7 +210,7 @@ public class ExternalBusinessEventTest extends BaseLoanIntegrationTest {
loanRescheduleRequestHelper.approveLoanRescheduleRequest(rescheduleLoansResponse.getResourceId(),
new PostUpdateRescheduleLoansRequest().approvedOnDate("1 March 2024").locale("en").dateFormat(DATETIME_PATTERN));
verifyBusinessEvents(new BusinessEvent("LoanRescheduledDueAdjustScheduleBusinessEvent", "01 March 2024", 300, 400.0, 400.0,
verifyBusinessEvents(new LoanBusinessEvent("LoanRescheduledDueAdjustScheduleBusinessEvent", "01 March 2024", 300, 400.0, 400.0,
List.of("interestRateForInstallment")));
});
}
@@ -260,11 +299,28 @@ public class ExternalBusinessEventTest extends BaseLoanIntegrationTest {
private void logBusinessEvents(List<ExternalEventDTO> allExternalEvents) {
allExternalEvents.forEach(externalEventDTO -> {
log.info("Event Received { type:'{}' businessDate:'{}' }", externalEventDTO.getType(), externalEventDTO.getBusinessDate());
log.debug(externalEventDTO.toString());
Object amount = externalEventDTO.getPayLoad().get("amount");
Object outstandingLoanBalance = externalEventDTO.getPayLoad().get("outstandingLoanBalance");
Object principalPortion = externalEventDTO.getPayLoad().get("principalPortion");
Object interestPortion = externalEventDTO.getPayLoad().get("interestPortion");
Object feePortion = externalEventDTO.getPayLoad().get("feeChargesPortion");
Object penaltyPortion = externalEventDTO.getPayLoad().get("penaltyChargesPortion");
log.info("Event Received\n type:'{}'\n businessDate:'{}'", externalEventDTO.getType(), externalEventDTO.getBusinessDate());
log.info(
"Values\n amount: {}\n outstandingLoanBalance: {}\n principalPortion: {}\n interestPortion: {}\n feePortion: {}\n penaltyPortion: {}",
amount, outstandingLoanBalance, principalPortion, interestPortion, feePortion, penaltyPortion);
});
}
private static void enableLoanInterestRefundPstBusinessEvent(boolean enabled) {
final Map<String, Boolean> updatedConfigurations = ExternalEventConfigurationHelper.updateExternalEventConfigurations(requestSpec,
responseSpec, "{\"externalEventConfigurations\":{\"LoanTransactionInterestRefundPostBusinessEvent\":"
+ (enabled ? "true" : "false") + "}}\n");
Assertions.assertEquals(updatedConfigurations.size(), 1);
Assertions.assertTrue(updatedConfigurations.containsKey("LoanTransactionInterestRefundPostBusinessEvent"));
Assertions.assertEquals(enabled, updatedConfigurations.get("LoanTransactionInterestRefundPostBusinessEvent"));
}
public void verifyBusinessEvents(BusinessEvent... businessEvents) {
List<ExternalEventDTO> allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec);
logBusinessEvents(allExternalEvents);
@@ -273,61 +329,119 @@ public class ExternalBusinessEventTest extends BaseLoanIntegrationTest {
Assertions.assertTrue(businessEvents.length <= allExternalEvents.size(),
"Expected business event count is less than actual. Expected: " + businessEvents.length + " Actual: "
+ allExternalEvents.size());
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN, Locale.ENGLISH);
final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN, Locale.ENGLISH);
for (BusinessEvent businessEvent : businessEvents) {
long count = allExternalEvents.stream().filter(externalEvent -> {
Object summaryRes = externalEvent.getPayLoad().get("summary");
Object statusRes = externalEvent.getPayLoad().get("status");
Map<String, Object> summary = summaryRes instanceof Map ? (Map<String, Object>) summaryRes : Map.of();
Map<String, Object> status = statusRes instanceof Map ? (Map<String, Object>) statusRes : Map.of();
var principalDisbursed = summary.get("principalDisbursed");
var principalOutstanding = summary.get("principalOutstanding");
var businessDate = LocalDate.parse(businessEvent.getBusinessDate(), formatter);
Double statusId = (Double) status.get("id");
return Objects.equals(externalEvent.getType(), businessEvent.getType())
&& Objects.equals(externalEvent.getBusinessDate(), businessDate)
&& Objects.equals(statusId, businessEvent.getStatusId().doubleValue())
&& Objects.equals(principalDisbursed, businessEvent.getPrincipalDisbursed())
&& Objects.equals(principalOutstanding, businessEvent.getPrincipalOutstanding())
&& loanTermVariationsMatch((List<Map<String, Object>>) externalEvent.getPayLoad().get("loanTermVariations"),
businessEvent.loanTermVariationType);
}).count();
long count = allExternalEvents.stream().filter(externalEvent -> businessEvent.verify(externalEvent, formatter)).count();
Assertions.assertEquals(1, count, "Expected business event not found " + businessEvent);
}
}
private boolean loanTermVariationsMatch(final List<Map<String, Object>> loanTermVariations, final List<String> expectedTypes) {
if (CollectionUtils.isEmpty(expectedTypes)) {
return true;
}
final long numberOfMatches = expectedTypes
.stream().filter(
expectedType -> loanTermVariations.stream()
.anyMatch(variation -> StringUtils
.equals((String) ((Map<String, Object>) variation.get("termType")).get("value"), expectedType)))
.count();
return numberOfMatches == expectedTypes.size();
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class BusinessEvent {
public BusinessEvent(String type, String businessDate, Integer statusId, Double principalDisbursed, Double principalOutstanding) {
this.type = type;
this.businessDate = businessDate;
String type;
String businessDate;
boolean verify(@NotNull ExternalEventDTO externalEvent, DateTimeFormatter formatter) {
var businessDate = LocalDate.parse(getBusinessDate(), formatter);
return Objects.equals(externalEvent.getType(), getType()) && Objects.equals(externalEvent.getBusinessDate(), businessDate);
}
}
@EqualsAndHashCode(callSuper = true)
@Data
@AllArgsConstructor
public static class LoanTransactionBusinessEvent extends BusinessEvent {
private Double amount;
private Double outstandingLoanBalance;
private Double principalPortion;
private Double interestPortion;
private Double feeChargesPortion;
private Double penaltyChargesPortion;
public LoanTransactionBusinessEvent(String type, String businessDate, Double amount, Double outstandingLoanBalance,
Double principalPortion, Double interestPortion, Double feeChargesPortion, Double penaltyChargesPortion) {
super(type, businessDate);
this.amount = amount;
this.outstandingLoanBalance = outstandingLoanBalance;
this.principalPortion = principalPortion;
this.interestPortion = interestPortion;
this.feeChargesPortion = feeChargesPortion;
this.penaltyChargesPortion = penaltyChargesPortion;
}
@Override
boolean verify(ExternalEventDTO externalEvent, DateTimeFormatter formatter) {
Object amount = externalEvent.getPayLoad().get("amount");
Object outstandingLoanBalance = externalEvent.getPayLoad().get("outstandingLoanBalance");
Object principalPortion = externalEvent.getPayLoad().get("principalPortion");
Object interestPortion = externalEvent.getPayLoad().get("interestPortion");
Object feePortion = externalEvent.getPayLoad().get("feeChargesPortion");
Object penaltyPortion = externalEvent.getPayLoad().get("penaltyChargesPortion");
return super.verify(externalEvent, formatter) && Objects.equals(amount, getAmount())
&& Objects.equals(outstandingLoanBalance, getOutstandingLoanBalance())
&& Objects.equals(principalPortion, getPrincipalPortion()) && Objects.equals(interestPortion, getInterestPortion())
&& Objects.equals(feePortion, getFeeChargesPortion()) && Objects.equals(penaltyPortion, getPenaltyChargesPortion());
}
}
@EqualsAndHashCode(callSuper = true)
@Data
@AllArgsConstructor
public static class LoanBusinessEvent extends BusinessEvent {
private Integer statusId;
private Double principalDisbursed;
private Double principalOutstanding;
private List<String> loanTermVariationType;
public LoanBusinessEvent(String type, String businessDate, Integer statusId, Double principalDisbursed,
Double principalOutstanding) {
super(type, businessDate);
this.statusId = statusId;
this.principalDisbursed = principalDisbursed;
this.principalOutstanding = principalOutstanding;
}
private String type;
private String businessDate;
private Integer statusId;
private Double principalDisbursed;
private Double principalOutstanding;
private List<String> loanTermVariationType;
public LoanBusinessEvent(String type, String businessDate, Integer statusId, Double principalDisbursed, Double principalOutstanding,
List<String> loanTermVariationType) {
super(type, businessDate);
this.statusId = statusId;
this.principalDisbursed = principalDisbursed;
this.principalOutstanding = principalOutstanding;
this.loanTermVariationType = loanTermVariationType;
}
@Override
public boolean verify(ExternalEventDTO externalEvent, DateTimeFormatter formatter) {
Object summaryRes = externalEvent.getPayLoad().get("summary");
Object statusRes = externalEvent.getPayLoad().get("status");
Map<String, Object> summary = summaryRes instanceof Map ? (Map<String, Object>) summaryRes : Map.of();
Map<String, Object> status = statusRes instanceof Map ? (Map<String, Object>) statusRes : Map.of();
var principalDisbursed = summary.get("principalDisbursed");
var principalOutstanding = summary.get("principalOutstanding");
Double statusId = (Double) status.get("id");
return super.verify(externalEvent, formatter) && Objects.equals(statusId, getStatusId().doubleValue())
&& Objects.equals(principalDisbursed, getPrincipalDisbursed())
&& Objects.equals(principalOutstanding, getPrincipalOutstanding()) && loanTermVariationsMatch(
(List<Map<String, Object>>) externalEvent.getPayLoad().get("loanTermVariations"), loanTermVariationType);
}
private boolean loanTermVariationsMatch(final List<Map<String, Object>> loanTermVariations, final List<String> expectedTypes) {
if (CollectionUtils.isEmpty(expectedTypes)) {
return true;
}
final long numberOfMatches = expectedTypes.stream().filter(expectedType -> loanTermVariations.stream().anyMatch(
variation -> StringUtils.equals((String) ((Map<String, Object>) variation.get("termType")).get("value"), expectedType)))
.count();
return numberOfMatches == expectedTypes.size();
}
}
}
@@ -0,0 +1,412 @@
/**
* 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;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.integrationtests.common.BusinessStepHelper;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@Slf4j
public class LoanInterestRefundTest extends BaseLoanIntegrationTest {
private static ResponseSpecification responseSpec;
private static RequestSpecification requestSpec;
private static LoanTransactionHelper loanTransactionHelper;
private static PostClientsResponse client;
private static BusinessStepHelper businessStepHelper;
@BeforeAll
public static void setup() {
Utils.initializeRESTAssured();
requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
requestSpec.header("Fineract-Platform-TenantId", "default");
responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build();
loanTransactionHelper = new LoanTransactionHelper(requestSpec, responseSpec);
ClientHelper clientHelper = new ClientHelper(requestSpec, responseSpec);
client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
}
@Test
public void verifyInterestRefundNotCreatedForPayoutRefundWhenTypesAreEmpty() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()) //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.9,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(1000.0, "Payout Refund", "22 January 2021"));
});
}
@Test
public void verifyInterestRefundNotCreatedForMerchantIssuedRefundWhenTypesAreEmpty() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>())//
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.9,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("MerchantIssuedRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(1000.0, "Merchant Issued Refund", "22 January 2021"));
});
}
@Test
public void verifyInterestRefundCreatedForPayoutRefund() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).addSupportedInterestRefundTypesItem("PAYOUT_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(1000.0, "Payout Refund", "22 January 2021"), transaction(5.75, "Accrual", "22 January 2021"),
transaction(5.75, "Interest Refund", "22 January 2021"));
});
}
@Test
public void verifyInterestRefundCreatedForMerchantIssuedRefund() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("MerchantIssuedRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(1000.0, "Merchant Issued Refund", "22 January 2021"), transaction(5.75, "Accrual", "22 January 2021"),
transaction(5.75, "Interest Refund", "22 January 2021"));
});
}
@Test
public void verifyUC01() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).addSupportedInterestRefundTypesItem("PAYOUT_REFUND") //
.addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(1000.0, "Payout Refund", "22 January 2021"), transaction(5.75, "Accrual", "22 January 2021"),
transaction(5.75, "Interest Refund", "22 January 2021"));
});
}
@Test
public void verifyUC02a() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).addSupportedInterestRefundTypesItem("PAYOUT_REFUND") //
.addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("1 February 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "1 February 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(1000.0, "Payout Refund", "01 February 2021"), transaction(8.48, "Accrual", "01 February 2021"),
transaction(8.48, "Interest Refund", "01 February 2021"));
});
}
@Test
public void verifyUC02b() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).addSupportedInterestRefundTypesItem("PAYOUT_REFUND") //
.addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(1000), "1 January 2021");
});
runAt("1 February 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper.makeLoanRepayment("Repayment",
"1 February 2021", 87.89F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(87.89, "Repayment", "01 February 2021"));
});
runAt("9 February 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "9 February 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(1000.0, "Disbursement", "01 January 2021"),
transaction(1000.0, "Payout Refund", "09 February 2021"), transaction(87.89, "Repayment", "01 February 2021"),
transaction(10.50, "Interest Refund", "09 February 2021"));
});
}
@Test
public void verifyUC03() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>())//
.disallowExpectedDisbursements(true)//
.multiDisburseLoan(true)//
.maxTrancheCount(2).addSupportedInterestRefundTypesItem("PAYOUT_REFUND") //
.addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(750), "1 January 2021");
disburseLoan(loanId, BigDecimal.valueOf(250), "1 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(750.0, "Disbursement", "01 January 2021"),
transaction(250.0, "Disbursement", "01 January 2021"), transaction(1000.0, "Payout Refund", "22 January 2021"),
transaction(5.75, "Accrual", "22 January 2021"), transaction(5.75, "Interest Refund", "22 January 2021"));
});
}
@Test
public void verifyUC04() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).disallowExpectedDisbursements(true).multiDisburseLoan(true)
.maxTrancheCount(2).addSupportedInterestRefundTypesItem("PAYOUT_REFUND") //
.addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(250), "1 January 2021");
});
runAt("4 January 2021", () -> {
Long loanId = loanIdRef.get();
disburseLoan(loanId, BigDecimal.valueOf(750), "4 January 2021");
});
runAt("22 January 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "22 January 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(750.0, "Disbursement", "04 January 2021"),
transaction(250.0, "Disbursement", "01 January 2021"), transaction(1000.0, "Payout Refund", "22 January 2021"),
transaction(5.14, "Accrual", "22 January 2021"), transaction(5.14, "Interest Refund", "22 January 2021"));
});
}
@Test
public void verifyUC05() {
AtomicReference<Long> loanIdRef = new AtomicReference<>();
runAt("1 January 2021", () -> {
PostLoanProductsResponse loanProduct = loanProductHelper
.createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL) //
.daysInYearType(DaysInYearType.ACTUAL) //
.supportedInterestRefundTypes(new ArrayList<>()).disallowExpectedDisbursements(true).multiDisburseLoan(true)
.maxTrancheCount(2).addSupportedInterestRefundTypesItem("PAYOUT_REFUND") //
.addSupportedInterestRefundTypesItem("MERCHANT_ISSUED_REFUND") //
.recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY) //
);
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "1 January 2021", 1000.0, 9.99,
12, null);
Assertions.assertNotNull(loanId);
loanIdRef.set(loanId);
disburseLoan(loanId, BigDecimal.valueOf(500), "1 January 2021");
});
runAt("7 January 2021", () -> {
Long loanId = loanIdRef.get();
disburseLoan(loanId, BigDecimal.valueOf(500), "7 January 2021");
});
runAt("1 February 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper.makeLoanRepayment("Repayment",
"1 February 2021", 87.82F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(500.0, "Disbursement", "01 January 2021"),
transaction(500.0, "Disbursement", "07 January 2021"), transaction(87.82, "Repayment", "01 February 2021"));
});
runAt("9 February 2021", () -> {
Long loanId = loanIdRef.get();
PostLoansLoanIdTransactionsResponse postLoansLoanIdTransactionsResponse = loanTransactionHelper
.makeLoanRepayment("PayoutRefund", "9 February 2021", 1000F, loanId.intValue());
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse);
Assertions.assertNotNull(postLoansLoanIdTransactionsResponse.getResourceId());
logTransactions(loanId);
verifyTransactions(loanId, transaction(500.0, "Disbursement", "01 January 2021"),
transaction(500.0, "Disbursement", "07 January 2021"), transaction(1000.0, "Payout Refund", "09 February 2021"),
transaction(87.82, "Repayment", "01 February 2021"), transaction(9.67, "Interest Refund", "09 February 2021"));
});
}
private void logTransactions(Long loanId) {
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue());
assert loanDetails.getTransactions() != null;
loanDetails.getTransactions()
.forEach(tr -> log.info("Transaction {} {} {} ", tr.getType().getValue(), tr.getDate(), tr.getAmount()));
}
}
@@ -44,7 +44,6 @@ import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.accounting.AccountHelper;
import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.apache.fineract.portfolio.common.domain.DaysInMonthType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
@@ -89,8 +88,8 @@ public class LoanRefundTransactionTest extends BaseLoanIntegrationTest {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(108.0).interestCalculationPeriodType(RepaymentFrequencyType.DAYS)
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.ACTUAL.getValue())
.daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(4)//
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.ACTUAL).daysInYearType(DaysInYearType.DAYS_360)
.numberOfRepayments(4)//
.maxInterestRatePerPeriod((double) 110)//
.repaymentEvery(1)//
.repaymentFrequencyType(1L)//
@@ -161,8 +160,8 @@ public class LoanRefundTransactionTest extends BaseLoanIntegrationTest {
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation()
.interestRatePerPeriod(108.0).interestCalculationPeriodType(RepaymentFrequencyType.DAYS)
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.ACTUAL.getValue())
.daysInYearType(DaysInYearType.DAYS_360).numberOfRepayments(4)//
.interestRateFrequencyType(YEARS).daysInMonthType(DaysInMonthType.ACTUAL).daysInYearType(DaysInYearType.DAYS_360)
.numberOfRepayments(4)//
.maxInterestRatePerPeriod((double) 110)//
.repaymentEvery(1)//
.repaymentFrequencyType(1L)//
@@ -560,6 +560,16 @@ public class ExternalEventConfigurationHelper {
loanTransactionAccrualActivityPreBusinessEvent.put("enabled", false);
defaults.add(loanTransactionAccrualActivityPreBusinessEvent);
Map<String, Object> loanTransactionInterestRefundPostBusinessEvent = new HashMap<>();
loanTransactionInterestRefundPostBusinessEvent.put("type", "LoanTransactionInterestRefundPostBusinessEvent");
loanTransactionInterestRefundPostBusinessEvent.put("enabled", false);
defaults.add(loanTransactionInterestRefundPostBusinessEvent);
Map<String, Object> loanTransactionInterestRefundPreBusinessEvent = new HashMap<>();
loanTransactionInterestRefundPreBusinessEvent.put("type", "LoanTransactionInterestRefundPreBusinessEvent");
loanTransactionInterestRefundPreBusinessEvent.put("enabled", false);
defaults.add(loanTransactionInterestRefundPreBusinessEvent);
return defaults;
}