diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index ca91b8ae6..9994201e7 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -157,6 +157,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep handleRepayment(loanTransaction, currency, installments, charges); case CHARGE_OFF -> handleChargeOff(loanTransaction, currency, installments); case CHARGE_PAYMENT -> handleChargePayment(loanTransaction, currency, installments, charges); + case WAIVE_CHARGES -> log.debug("WAIVE_CHARGES transaction will not be processed."); // TODO: Cover rest of the transaction types default -> { log.warn("Unhandled transaction processing for transaction type: {}", loanTransaction.getTypeOf()); @@ -224,7 +225,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep ChangedTransactionDetail changedTransactionDetail) { if (loanTransaction.getId() == null) { processLatestTransaction(loanTransaction, currency, installments, charges, Money.zero(currency)); - loanTransaction.adjustInterestComponent(currency); + if (loanTransaction.isInterestWaiver()) { + loanTransaction.adjustInterestComponent(currency); + } } else { /* * For existing transactions, check if the re-payment breakup (principal, interest, fees, penalties) has @@ -235,7 +238,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep // Reset derived component of new loan transaction and // re-process transaction processLatestTransaction(newLoanTransaction, currency, installments, charges, Money.zero(currency)); - newLoanTransaction.adjustInterestComponent(currency); + if (loanTransaction.isInterestWaiver()) { + newLoanTransaction.adjustInterestComponent(currency); + } /* * Check if the transaction amounts have changed. If so, reverse the original transaction and update * changedTransactionDetail accordingly diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationWaiveLoanCharges.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationWaiveLoanCharges.java new file mode 100644 index 000000000..0bcad0276 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationWaiveLoanCharges.java @@ -0,0 +1,189 @@ +/** + * 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 static org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.AdvancedPaymentData; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.PaymentAllocationOrder; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdRequest; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@Slf4j +@ExtendWith(LoanTestLifecycleExtension.class) +public class AdvancedPaymentAllocationWaiveLoanCharges extends BaseLoanIntegrationTest { + + @Test + public void testAddFeeAndWaiveAdvancedPaymentAllocationNoBackdated() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProductWithAdvancedAllocation(); + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1000.0, 1, + (req) -> req.setTransactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)); + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023"); + // Add Penalty + Long loanChargeId = addCharge(loanId, false, 50, "01 January 2023"); + // When Waive Created Penalty + loanTransactionHelper.waiveLoanCharge(loanId, loanChargeId, new PostLoansLoanIdChargesChargeIdRequest()); + + // Then verify + verifyTransactions(loanId, // + transaction(1000, "Disbursement", "01 January 2023", 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(50, "Waive loan charges", "01 January 2023", 1000.0, 0.0, 0.0, 0.0, 0.0, 50.0) // + ); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue()); + GetLoansLoanIdTransactions waiveTransaction = loanDetails.getTransactions().get(1); + Assertions.assertNotNull(waiveTransaction.getLoanChargePaidByList()); + Assertions.assertEquals(1, waiveTransaction.getLoanChargePaidByList().size()); + Assertions.assertEquals(loanChargeId, waiveTransaction.getLoanChargePaidByList().get(0).getChargeId()); + Assertions.assertEquals(50.0, waiveTransaction.getLoanChargePaidByList().get(0).getAmount()); + }); + } + + @Test + public void testAddPenaltyAndWaiveAdvancedPaymentAllocationNoBackDated() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProductWithAdvancedAllocation(); + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1000.0, 1, + (req) -> req.setTransactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)); + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023"); + // Add Penalty + Long loanChargeId = addCharge(loanId, true, 50, "01 January 2023"); + // When Waive Created Penalty + loanTransactionHelper.waiveLoanCharge(loanId, loanChargeId, new PostLoansLoanIdChargesChargeIdRequest()); + + // Then verify + verifyTransactions(loanId, // + transaction(1000, "Disbursement", "01 January 2023", 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(50, "Waive loan charges", "01 January 2023", 1000.0, 0.0, 0.0, 0.0, 0.0, 50.0) // + ); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue()); + GetLoansLoanIdTransactions waiveTransaction = loanDetails.getTransactions().get(1); + Assertions.assertNotNull(waiveTransaction.getLoanChargePaidByList()); + Assertions.assertEquals(1, waiveTransaction.getLoanChargePaidByList().size()); + Assertions.assertEquals(loanChargeId, waiveTransaction.getLoanChargePaidByList().get(0).getChargeId()); + Assertions.assertEquals(50.0, waiveTransaction.getLoanChargePaidByList().get(0).getAmount()); + }); + } + + @Test + public void testAddPenaltyAndWaiveAdvancedPaymentAllocationAndBackdatedRepayment() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProductWithAdvancedAllocation(); + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1000.0, 1, + (req) -> req.setTransactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)); + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023"); + + // set business date to + updateBusinessDate("05 January 2023"); + + // Add Penalty + Long loanChargeId = addCharge(loanId, true, 50, "05 January 2023"); + + // When Waive Created Penalty + loanTransactionHelper.waiveLoanCharge(loanId, loanChargeId, new PostLoansLoanIdChargesChargeIdRequest()); + + // Then verify + verifyTransactions(loanId, // + transaction(1000, "Disbursement", "01 January 2023", 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(50, "Waive loan charges", "05 January 2023", 1000.0, 0.0, 0.0, 0.0, 0.0, 50.0) // + ); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue()); + GetLoansLoanIdTransactions waiveTransaction = loanDetails.getTransactions().get(1); + Assertions.assertNotNull(waiveTransaction.getLoanChargePaidByList()); + Assertions.assertEquals(1, waiveTransaction.getLoanChargePaidByList().size()); + Assertions.assertEquals(loanChargeId, waiveTransaction.getLoanChargePaidByList().get(0).getChargeId()); + Assertions.assertEquals(50.0, waiveTransaction.getLoanChargePaidByList().get(0).getAmount()); + + addRepaymentForLoan(loanId, 200.0, "03 January 2023"); + + verifyTransactions(loanId, // + transaction(1000, "Disbursement", "01 January 2023", 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(200, "Repayment", "03 January 2023", 800.0, 200.0, 0.0, 0.0, 0.0, 0.0), // + transaction(50, "Waive loan charges", "05 January 2023", 800.0, 0.0, 0.0, 0.0, 0.0, 50.0) // + ); + }); + } + + private AdvancedPaymentData createDefaultPaymentAllocationWithMixedGrouping() { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("DEFAULT"); + advancedPaymentData.setFutureInstallmentAllocationRule("NEXT_INSTALLMENT"); + + List paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_PRINCIPAL, PaymentAllocationType.PAST_DUE_INTEREST, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_PRINCIPAL, + PaymentAllocationType.DUE_INTEREST, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private List getPaymentAllocationOrder(PaymentAllocationType... paymentAllocationTypes) { + AtomicInteger integer = new AtomicInteger(1); + return Arrays.stream(paymentAllocationTypes).map(pat -> { + PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); + paymentAllocationOrder.setPaymentAllocationRule(pat.name()); + paymentAllocationOrder.setOrder(integer.getAndIncrement()); + return paymentAllocationOrder; + }).collect(Collectors.toList()); + } + + protected Long createLoanProductWithAdvancedAllocation() { + PostLoanProductsRequest req = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct(); + req.setTransactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY); + req.addPaymentAllocationItem(createDefaultPaymentAllocationWithMixedGrouping()); + PostLoanProductsResponse loanProduct = loanTransactionHelper.createLoanProduct(req); + return loanProduct.getResourceId(); + } + +} 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 e7c34f46b..6d8ec7b7c 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 @@ -38,6 +38,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Objects; import java.util.UUID; +import java.util.function.Consumer; import lombok.AllArgsConstructor; import lombok.ToString; import org.apache.fineract.client.models.AllowAttributeOverrides; @@ -59,6 +60,7 @@ import org.apache.fineract.integrationtests.common.Utils; import org.apache.fineract.integrationtests.common.accounting.Account; import org.apache.fineract.integrationtests.common.accounting.AccountHelper; import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; import org.apache.fineract.integrationtests.common.loans.LoanProductHelper; import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; @@ -224,7 +226,7 @@ public abstract class BaseLoanIntegrationTest { } protected void verifyNoTransactions(Long loanId) { - verifyTransactions(loanId); + verifyTransactions(loanId, (Transaction[]) null); } protected void verifyTransactions(Long loanId, Transaction... transactions) { @@ -242,6 +244,28 @@ public abstract class BaseLoanIntegrationTest { } } + protected void verifyTransactions(Long loanId, TransactionExt... transactions) { + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue()); + if (transactions == null || transactions.length == 0) { + assertNull(loanDetails.getTransactions(), "No transaction is expected"); + } else { + Assertions.assertEquals(transactions.length, loanDetails.getTransactions().size()); + Arrays.stream(transactions).forEach(tr -> { + boolean found = loanDetails.getTransactions().stream().anyMatch(item -> Objects.equals(item.getAmount(), tr.amount) // + && Objects.equals(item.getType().getValue(), tr.type) // + && Objects.equals(item.getDate(), LocalDate.parse(tr.date, dateTimeFormatter)) // + && Objects.equals(item.getOutstandingLoanBalance(), tr.outstandingAmount) // + && Objects.equals(item.getPrincipalPortion(), tr.principalPortion) // + && Objects.equals(item.getInterestPortion(), tr.interestPortion) // + && Objects.equals(item.getFeeChargesPortion(), tr.feePortion) // + && Objects.equals(item.getPenaltyChargesPortion(), tr.penaltyPortion) // + && Objects.equals(item.getUnrecognizedIncomePortion(), tr.unrecognizedPortion) // + ); + Assertions.assertTrue(found, "Required transaction not found: " + tr); + }); + } + } + protected void disburseLoan(Long loanId, BigDecimal amount, String date) { loanTransactionHelper.disburseLoan(loanId, new PostLoansLoanIdRequest().actualDisbursementDate(date).dateFormat(DATETIME_PATTERN) .transactionAmount(amount).locale("en")); @@ -259,6 +283,16 @@ public abstract class BaseLoanIntegrationTest { }); } + protected Long addCharge(Long loanId, boolean isPenalty, double amount, String dueDate) { + Integer chargeId = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, String.valueOf(amount), isPenalty)); + Assertions.assertNotNull(chargeId); + Integer loanChargeId = this.loanTransactionHelper.addChargesForLoan(loanId.intValue(), + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(chargeId), dueDate, String.valueOf(amount))); + Assertions.assertNotNull(loanChargeId); + return loanChargeId.longValue(); + } + protected void verifyRepaymentSchedule(Long loanId, Installment... installments) { GetLoansLoanIdResponse loanResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue()); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN); @@ -316,13 +350,23 @@ public abstract class BaseLoanIntegrationTest { protected PostLoansRequest applyLoanRequest(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, int numberOfRepayments) { - return new PostLoansRequest().clientId(clientId).productId(loanProductId).expectedDisbursementDate(loanDisbursementDate) - .dateFormat(DATETIME_PATTERN) + return applyLoanRequest(clientId, loanProductId, loanDisbursementDate, amount, numberOfRepayments, null); + } + + protected PostLoansRequest applyLoanRequest(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, + int numberOfRepayments, Consumer customizer) { + + PostLoansRequest postLoansRequest = new PostLoansRequest().clientId(clientId).productId(loanProductId) + .expectedDisbursementDate(loanDisbursementDate).dateFormat(DATETIME_PATTERN) .transactionProcessingStrategyCode(DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY) .locale("en").submittedOnDate(loanDisbursementDate).amortizationType(1).interestRatePerPeriod(0) .interestCalculationPeriodType(1).interestType(0).repaymentFrequencyType(0).repaymentEvery(30).repaymentFrequencyType(0) .numberOfRepayments(numberOfRepayments).loanTermFrequency(numberOfRepayments * 30).loanTermFrequencyType(0) .maxOutstandingLoanBalance(BigDecimal.valueOf(amount)).principal(BigDecimal.valueOf(amount)).loanType("individual"); + if (customizer != null) { + customizer.accept(postLoansRequest); + } + return postLoansRequest; } protected PostLoansLoanIdRequest approveLoanRequest(Double amount) { @@ -336,9 +380,9 @@ public abstract class BaseLoanIntegrationTest { } protected Long applyAndApproveLoan(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, - int numberOfRepayments, String externalId) { - PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan( - applyLoanRequest(clientId, loanProductId, loanDisbursementDate, amount, numberOfRepayments).externalId(externalId)); + int numberOfRepayments, Consumer customizer) { + PostLoansResponse postLoansResponse = loanTransactionHelper + .applyLoan(applyLoanRequest(clientId, loanProductId, loanDisbursementDate, amount, numberOfRepayments, customizer)); PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), approveLoanRequest(amount)); @@ -356,6 +400,11 @@ public abstract class BaseLoanIntegrationTest { .transactionDate(date).locale("en").transactionAmount(amount).externalId(firstRepaymentUUID)); } + protected void updateBusinessDate(String date) { + businessDateHelper.updateBusinessDate( + new BusinessDateRequest().type(BUSINESS_DATE.getName()).date(date).dateFormat(DATETIME_PATTERN).locale("en")); + } + protected JournalEntry journalEntry(double principalAmount, Account account, String type) { return new JournalEntry(principalAmount, account, type); } @@ -364,6 +413,12 @@ public abstract class BaseLoanIntegrationTest { return new Transaction(principalAmount, type, date); } + protected TransactionExt transaction(double amount, String type, String date, double outstandingAmount, double principalPortion, + double interestPortion, double feePortion, double penaltyPortion, double unrecognizedIncomePortion) { + return new TransactionExt(amount, type, date, outstandingAmount, principalPortion, interestPortion, feePortion, penaltyPortion, + unrecognizedIncomePortion); + } + protected Installment installment(double principalAmount, Boolean completed, String dueDate) { return new Installment(principalAmount, null, null, completed, dueDate); } @@ -382,6 +437,21 @@ public abstract class BaseLoanIntegrationTest { String date; } + @ToString + @AllArgsConstructor + public static class TransactionExt { + + Double amount; + String type; + String date; + Double outstandingAmount; + Double principalPortion; + Double interestPortion; + Double feePortion; + Double penaltyPortion; + Double unrecognizedPortion; + } + @ToString @AllArgsConstructor public static class JournalEntry { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java index 1d1fc3fa7..2f95ef397 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java @@ -95,7 +95,7 @@ public class DelinquencyActionIntegrationTests extends BaseLoanIntegrationTest { String externalId = UUID.randomUUID().toString(); // Apply and Approve Loan - Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1500.0, 2, externalId); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1500.0, 2, req -> req.externalId(externalId)); // Disburse Loan disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023");