diff --git a/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanTransactionInterestRefundPostBusinessEvent.java b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanTransactionInterestRefundPostBusinessEvent.java new file mode 100644 index 00000000000..cae0926e8ec --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanTransactionInterestRefundPostBusinessEvent.java @@ -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; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanTransactionInterestRefundPreBusinessEvent.java b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanTransactionInterestRefundPreBusinessEvent.java new file mode 100644 index 00000000000..b0b15c53767 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanTransactionInterestRefundPreBusinessEvent.java @@ -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; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index a99c7ecb73b..38d5bc01e8d 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -1718,6 +1718,7 @@ public Map undoApproval(final LoanLifecycleStateMachine loanLife public List findExistingTransactionIds() { return getLoanTransactions().stream() // + .filter(loanTransaction -> loanTransaction.getId() != null) // .map(LoanTransaction::getId) // .collect(Collectors.toList()); } @@ -1725,6 +1726,7 @@ public List findExistingTransactionIds() { public List findExistingReversedTransactionIds() { return getLoanTransactions().stream() // .filter(LoanTransaction::isReversed) // + .filter(loanTransaction -> loanTransaction.getId() != null) // .map(LoanTransaction::getId) // .collect(Collectors.toList()); } @@ -2256,7 +2258,8 @@ public ChangedTransactionDetail makeRepayment(final LoanTransaction repaymentTra 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 dataValidationErrors = new ArrayList<>(); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index da4c38c3b62..469c0b52708 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -189,6 +189,11 @@ public static LoanTransaction repaymentType(final LoanTransactionType repaymentT 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 void setManuallyAdjustedOrReversed() { public boolean isRepaymentLikeType() { return isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund() - || isChargeAdjustment() || isDownPayment() || isInterestPaymentWaiver(); + || isChargeAdjustment() || isDownPayment() || isInterestPaymentWaiver() || isInterestRefund(); } public boolean isTypeAllowedForChargeback() { @@ -1118,6 +1123,10 @@ public boolean isOverPaid() { 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. } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestRefundService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestRefundService.java new file mode 100644 index 00000000000..266d025bf1d --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestRefundService.java @@ -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); + +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestRefundServiceDelegate.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestRefundServiceDelegate.java new file mode 100644 index 00000000000..a4aa2f06282 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestRefundServiceDelegate.java @@ -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; + + public InterestRefundService lookupInterestRefundService(final Loan loan) { + return interestRefundService.stream().filter(iRS -> iRS.canHandle(loan)).findFirst().orElse(null); + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 32f44df2595..7499fccaa0c 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -96,6 +96,7 @@ 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 String getCode() { @Override public String getName() { - return "Advanced payment allocation strategy"; + return ADVANCED_PAYMENT_ALLOCATION_STRATEGY_NAME; } @Override @@ -232,8 +233,8 @@ public void processLatestTransaction(LoanTransaction loanTransaction, Transactio 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java index a83c3059182..431fd13197d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java @@ -55,6 +55,8 @@ 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.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 void updateLoanCollateralStatus(Set loanCollate 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 LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact 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 LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact } 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 LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact 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 LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact } } } - + if (shouldCreateInterestRefundTransaction && newInterestRefundTransaction != null) { + businessEventNotifierService + .notifyPostBusinessEvent(new LoanTransactionInterestRefundPostBusinessEvent(newInterestRefundTransaction)); + } return newRepaymentTransaction; } @@ -299,6 +342,8 @@ private LoanBusinessEvent getLoanRepaymentTypeBusinessEvent(LoanTransactionType 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 @@ private LoanTransactionBusinessEvent getTransactionRepaymentTypeBusinessEvent(Lo repaymentEvent = new LoanTransactionRecoveryPaymentPostBusinessEvent(transaction); } else if (repaymentTransactionType.isDownPayment()) { repaymentEvent = new LoanTransactionDownPaymentPostBusinessEvent(transaction); + } else if (repaymentTransactionType.isInterestRefund()) { + repaymentEvent = new LoanTransactionInterestRefundPostBusinessEvent(transaction); } return repaymentEvent; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanInterestRefundServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanInterestRefundServiceImpl.java new file mode 100644 index 00000000000..13253ac7ca2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanInterestRefundServiceImpl.java @@ -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 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 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 refundFinal = new AtomicReference<>(refundAmount); + List 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 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); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index a0d7644b596..8ac88161cf7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -169,4 +169,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0151_interest_refund_business_events.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0151_interest_refund_business_events.xml new file mode 100644 index 00000000000..6cab64e728c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0151_interest_refund_business_events.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index 505a209b807..47e57821fd9 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -104,7 +104,8 @@ public void givenAllConfigurationWhenValidatedThenValidationSuccessful() throws "LoanUndoReAmortizeTransactionBusinessEvent", "LoanReAgeBusinessEvent", "LoanUndoReAgeBusinessEvent", "LoanReAmortizeBusinessEvent", "LoanUndoReAmortizeBusinessEvent", "LoanTransactionInterestPaymentWaiverPreBusinessEvent", "LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent", - "LoanTransactionAccrualActivityPreBusinessEvent"); + "LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent", + "LoanTransactionInterestRefundPreBusinessEvent"); List tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); @@ -189,7 +190,8 @@ public void givenMissingEventConfigurationWhenValidatedThenThrowException() thro "LoanUndoReAmortizeTransactionBusinessEvent", "LoanReAgeBusinessEvent", "LoanUndoReAgeBusinessEvent", "LoanReAmortizeBusinessEvent", "LoanUndoReAmortizeBusinessEvent", "LoanTransactionInterestPaymentWaiverPreBusinessEvent", "LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent", - "LoanTransactionAccrualActivityPreBusinessEvent"); + "LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent", + "LoanTransactionInterestRefundPreBusinessEvent"); List tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java index 69c14f0bbd9..b91140e8e0c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java @@ -76,7 +76,6 @@ 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 void uc141() { 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 void uc142() { 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 void uc143() { 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 void uc144() { 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 void uc145() { 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 void uc146() { 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 void uc147a() { 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 void uc147b() { 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 void uc147c() { .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)// diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index f99b79e1ecd..7d751155f14 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -1281,6 +1281,13 @@ public static class DaysInYearType { 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"; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ExternalBusinessEventTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ExternalBusinessEventTest.java index 33f4fbe8e30..6aee674af0d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ExternalBusinessEventTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ExternalBusinessEventTest.java @@ -25,9 +25,11 @@ 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.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.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 void testExternalBusinessEventLoanBalanceChangedBusinessEventOnMultiDisbu 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 void testExternalBusinessEventLoanBalanceChangedBusinessEventOnMultiDisbu 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 void testExternalBusinessEventLoanBalanceChangedBusinessEventOnMultiDisbu .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 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 loanIdRef = new AtomicReference<>(); @@ -171,7 +210,7 @@ public void testExternalBusinessEventLoanRescheduledDueAdjustScheduleBusinessEve 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 @@ private static Long applyForLoanApplicationWithInterest(final Long clientId, fin private void logBusinessEvents(List 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 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 allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); logBusinessEvents(allExternalEvents); @@ -273,61 +329,119 @@ public void verifyBusinessEvents(BusinessEvent... businessEvents) { 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 summary = summaryRes instanceof Map ? (Map) summaryRes : Map.of(); - Map status = statusRes instanceof Map ? (Map) 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>) 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> loanTermVariations, final List expectedTypes) { - if (CollectionUtils.isEmpty(expectedTypes)) { - return true; + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class BusinessEvent { + + 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); } - final long numberOfMatches = expectedTypes - .stream().filter( - expectedType -> loanTermVariations.stream() - .anyMatch(variation -> StringUtils - .equals((String) ((Map) variation.get("termType")).get("value"), expectedType))) - .count(); - - return numberOfMatches == expectedTypes.size(); } + @EqualsAndHashCode(callSuper = true) @Data @AllArgsConstructor - public static class BusinessEvent { + 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; + } - public BusinessEvent(String type, String businessDate, Integer statusId, Double principalDisbursed, Double principalOutstanding) { - this.type = type; - this.businessDate = businessDate; - this.statusId = statusId; - this.principalDisbursed = principalDisbursed; - this.principalOutstanding = principalOutstanding; + @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 String type; - private String businessDate; private Integer statusId; private Double principalDisbursed; private Double principalOutstanding; private List 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; + } + + public LoanBusinessEvent(String type, String businessDate, Integer statusId, Double principalDisbursed, Double principalOutstanding, + List 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 summary = summaryRes instanceof Map ? (Map) summaryRes : Map.of(); + Map status = statusRes instanceof Map ? (Map) 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>) externalEvent.getPayLoad().get("loanTermVariations"), loanTermVariationType); + } + + private boolean loanTermVariationsMatch(final List> loanTermVariations, final List expectedTypes) { + if (CollectionUtils.isEmpty(expectedTypes)) { + return true; + } + final long numberOfMatches = expectedTypes.stream().filter(expectedType -> loanTermVariations.stream().anyMatch( + variation -> StringUtils.equals((String) ((Map) variation.get("termType")).get("value"), expectedType))) + .count(); + + return numberOfMatches == expectedTypes.size(); + } } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInterestRefundTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInterestRefundTest.java new file mode 100644 index 00000000000..858f334ef7d --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanInterestRefundTest.java @@ -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 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 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 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 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 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 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 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 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 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 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())); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRefundTransactionTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRefundTransactionTest.java index fe90383faf9..29ac1ce4c02 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRefundTransactionTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanRefundTransactionTest.java @@ -44,7 +44,6 @@ 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 void uc1() { 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 void uc2() { 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)// diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java index 7d3406c6811..9c35d61e4bd 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java @@ -560,6 +560,16 @@ public static ArrayList> getDefaultExternalEventConfiguratio loanTransactionAccrualActivityPreBusinessEvent.put("enabled", false); defaults.add(loanTransactionAccrualActivityPreBusinessEvent); + Map loanTransactionInterestRefundPostBusinessEvent = new HashMap<>(); + loanTransactionInterestRefundPostBusinessEvent.put("type", "LoanTransactionInterestRefundPostBusinessEvent"); + loanTransactionInterestRefundPostBusinessEvent.put("enabled", false); + defaults.add(loanTransactionInterestRefundPostBusinessEvent); + + Map loanTransactionInterestRefundPreBusinessEvent = new HashMap<>(); + loanTransactionInterestRefundPreBusinessEvent.put("type", "LoanTransactionInterestRefundPreBusinessEvent"); + loanTransactionInterestRefundPreBusinessEvent.put("enabled", false); + defaults.add(loanTransactionInterestRefundPreBusinessEvent); + return defaults; }