FINERACT-1971: Fix when waived N+1 installment's obligationsMet and obligationsMetOnDate is not or wrongly set

This commit is contained in:
Arnold Galovics
2023-12-04 14:09:10 +01:00
committed by Arnold Gálovics
parent 8505145fde
commit 646d7738c2
6 changed files with 258 additions and 5 deletions
@@ -1436,6 +1436,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
this.charges);
updateLoanOutstandingBalances();
}
}
public void updateLoanSummaryAndStatus() {
@@ -26,6 +26,7 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
@@ -199,7 +200,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
recalculateChargeOffTransaction(changedTransactionDetail, loanTransaction, currency, installments);
}
}
reprocessInstallments(installments, currency);
reprocessInstallments(disbursementDate, transactionsToBeProcessed, installments, currency);
return changedTransactionDetail;
}
@@ -385,11 +386,28 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
}
}
protected void reprocessInstallments(List<LoanRepaymentScheduleInstallment> installments, MonetaryCurrency currency) {
protected void reprocessInstallments(LocalDate disbursementDate, List<LoanTransaction> transactions,
List<LoanRepaymentScheduleInstallment> installments, MonetaryCurrency currency) {
LoanRepaymentScheduleInstallment lastInstallment = installments.get(installments.size() - 1);
if (lastInstallment.isAdditional() && lastInstallment.getDue(currency).isZero()) {
installments.remove(lastInstallment);
}
if (isNotObligationsMet(lastInstallment) || isObligationsMetOnDisbursementDate(disbursementDate, lastInstallment)) {
Optional<LoanTransaction> optWaiverTx = transactions.stream().filter(lt -> {
LocalDate fromDate = lastInstallment.getFromDate();
return lt.getTransactionDate().isAfter(fromDate);
}).filter(LoanTransaction::isChargesWaiver).max(Comparator.comparing(LoanTransaction::getTransactionDate));
if (optWaiverTx.isPresent()) {
LoanTransaction waiverTx = optWaiverTx.get();
LocalDate waiverTxDate = waiverTx.getTransactionDate();
if (isNotObligationsMet(lastInstallment) || isTransactionAfterObligationsMetOnDate(waiverTxDate, lastInstallment)) {
lastInstallment.updateObligationMet(true);
lastInstallment.updateObligationMetOnDate(waiverTxDate);
}
}
}
// TODO: rewrite and handle it at the proper place when disbursement handling got fixed
for (LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment : installments) {
if (loanRepaymentScheduleInstallment.getTotalOutstanding(currency).isGreaterThanZero()) {
@@ -399,6 +417,20 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
}
}
private boolean isTransactionAfterObligationsMetOnDate(LocalDate waiverTxDate, LoanRepaymentScheduleInstallment lastInstallment) {
return lastInstallment.getObligationsMetOnDate() != null && lastInstallment.getObligationsMetOnDate().isBefore(waiverTxDate);
}
private boolean isObligationsMetOnDisbursementDate(LocalDate disbursementDate,
LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment) {
return loanRepaymentScheduleInstallment.isObligationsMet()
&& disbursementDate.equals(loanRepaymentScheduleInstallment.getObligationsMetOnDate());
}
private boolean isNotObligationsMet(LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment) {
return !loanRepaymentScheduleInstallment.isObligationsMet() && loanRepaymentScheduleInstallment.getObligationsMetOnDate() == null;
}
private void recalculateCreditTransaction(ChangedTransactionDetail changedTransactionDetail, LoanTransaction loanTransaction,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
List<LoanTransaction> transactionsToBeProcessed) {
@@ -30,6 +30,7 @@ import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
@@ -139,7 +140,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
chargeOrTransaction.getLoanCharge()
.ifPresent(loanCharge -> processSingleCharge(loanCharge, currency, installments, disbursementDate));
}
reprocessInstallments(installments, currency);
List<LoanTransaction> txs = chargeOrTransactions.stream().map(ChargeOrTransaction::getLoanTransaction).filter(Optional::isPresent)
.map(Optional::get).toList();
reprocessInstallments(disbursementDate, txs, installments, currency);
return changedTransactionDetail;
}
@@ -36,17 +36,23 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import lombok.AllArgsConstructor;
import lombok.ToString;
import org.apache.fineract.client.models.AdvancedPaymentData;
import org.apache.fineract.client.models.AllowAttributeOverrides;
import org.apache.fineract.client.models.BusinessDateRequest;
import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PaymentAllocationOrder;
import org.apache.fineract.client.models.PostChargesResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
@@ -63,9 +69,13 @@ 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.LoanTestLifecycleExtension;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(LoanTestLifecycleExtension.class)
public abstract class BaseLoanIntegrationTest {
static {
@@ -190,6 +200,40 @@ public abstract class BaseLoanIntegrationTest {
.incomeFromChargeOffPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue());
}
protected PostLoanProductsRequest createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() {
String futureInstallmentAllocationRule = "NEXT_INSTALLMENT";
AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(futureInstallmentAllocationRule);
return createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() //
.transactionProcessingStrategyCode("advanced-payment-allocation-strategy")//
.addPaymentAllocationItem(defaultAllocation);
}
private List<PaymentAllocationOrder> 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;
}).toList();
}
private AdvancedPaymentData createDefaultPaymentAllocation(String futureInstallmentAllocationRule) {
AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData();
advancedPaymentData.setTransactionType("DEFAULT");
advancedPaymentData.setFutureInstallmentAllocationRule(futureInstallmentAllocationRule);
List<PaymentAllocationOrder> 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;
}
protected PostLoanProductsRequest create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct(
int interestType, int amortizationType) {
return createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct().multiDisburseLoan(false)//
@@ -322,6 +366,14 @@ public abstract class BaseLoanIntegrationTest {
"%d. installment's interest due is different, expected: %.2f, actual: %.2f".formatted(i, interestAmount,
interestDue));
}
Double feeAmount = installments[i].feeAmount;
Double feeDue = period.getFeeChargesDue();
if (feeAmount != null) {
Assertions.assertEquals(feeAmount, feeDue,
"%d. installment's fee charges due is different, expected: %.2f, actual: %.2f".formatted(i, feeAmount, feeDue));
}
Double outstandingAmount = installments[i].totalOutstandingAmount;
Double totalOutstanding = period.getTotalOutstandingForPeriod();
if (outstandingAmount != null) {
@@ -329,6 +381,7 @@ public abstract class BaseLoanIntegrationTest {
"%d. installment's total outstanding is different, expected: %.2f, actual: %.2f".formatted(i, outstandingAmount,
totalOutstanding));
}
}
Assertions.assertEquals(installments[i].completed, period.getComplete());
Assertions.assertEquals(LocalDate.parse(installments[i].dueDate, dateTimeFormatter), period.getDueDate());
@@ -400,6 +453,21 @@ public abstract class BaseLoanIntegrationTest {
.transactionDate(date).locale("en").transactionAmount(amount).externalId(firstRepaymentUUID));
}
protected PostChargesResponse createCharge(Double amount) {
String payload = ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, amount.toString(), false);
return ChargesHelper.createLoanCharge(requestSpec, responseSpec, payload);
}
protected PostLoansLoanIdChargesResponse addLoanCharge(Long loanId, Long chargeId, String date, Double amount) {
String payload = LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(chargeId.toString(), date, amount.toString());
return loanTransactionHelper.addChargeForLoan(loanId.intValue(), payload, responseSpec);
}
protected void waiveLoanCharge(Long loanId, Long chargeId, Integer installmentNumber) {
String payload = LoanTransactionHelper.getWaiveChargeJSON(installmentNumber.toString());
loanTransactionHelper.waiveChargesForLoan(loanId.intValue(), chargeId.intValue(), payload);
}
protected void updateBusinessDate(String date) {
businessDateHelper.updateBusinessDate(
new BusinessDateRequest().type(BUSINESS_DATE.getName()).date(date).dateFormat(DATETIME_PATTERN).locale("en"));
@@ -420,12 +488,17 @@ public abstract class BaseLoanIntegrationTest {
}
protected Installment installment(double principalAmount, Boolean completed, String dueDate) {
return new Installment(principalAmount, null, null, completed, dueDate);
return new Installment(principalAmount, null, null, null, completed, dueDate);
}
protected Installment installment(double principalAmount, double interestAmount, double totalOutstandingAmount, Boolean completed,
String dueDate) {
return new Installment(principalAmount, interestAmount, totalOutstandingAmount, completed, dueDate);
return new Installment(principalAmount, interestAmount, null, totalOutstandingAmount, completed, dueDate);
}
protected Installment installment(double principalAmount, double interestAmount, double feeAmount, double totalOutstandingAmount,
Boolean completed, String dueDate) {
return new Installment(principalAmount, interestAmount, feeAmount, totalOutstandingAmount, completed, dueDate);
}
@ToString
@@ -467,6 +540,7 @@ public abstract class BaseLoanIntegrationTest {
Double principalAmount;
Double interestAmount;
Double feeAmount;
Double totalOutstandingAmount;
Boolean completed;
String dueDate;
@@ -0,0 +1,142 @@
/**
* 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.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.common.collect.Streams;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostChargesResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse;
import org.apache.fineract.client.models.PostLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
public class LoanWaiveChargeTest extends BaseLoanIntegrationTest {
private static Stream<Arguments> processingStrategy() {
return Stream.of(Arguments.of(Named.of("originalStrategy", false)), //
Arguments.of(Named.of("advancedStrategy", true)));
}
@ParameterizedTest
@MethodSource("processingStrategy")
public void test_LoanPaidByDateIsCorrect_WhenNPlusOneInstallmentCharge_IsWaived(boolean advancedPaymentStrategy) {
double amount = 1000.0;
AtomicLong appliedLoanId = new AtomicLong();
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
// Create Loan Product
PostLoanProductsRequest product;
if (advancedPaymentStrategy) {
product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation();
} else {
product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct();
}
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
// Apply and Approve Loan
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, 1);
if (advancedPaymentStrategy) {
applicationRequest = applicationRequest
.transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
}
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount));
Long loanId = approvedLoanResult.getLoanId();
appliedLoanId.set(loanId);
// disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(amount), "01 January 2023");
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(0.0, null, "01 January 2023"), //
installment(1000.0, 0.0, 0.0, 1000.0, false, "31 January 2023"));
});
runAt("02 February 2023", () -> {
Long loanId = appliedLoanId.get();
// create charge
double chargeAmount = 100.0;
PostChargesResponse chargeResult = createCharge(chargeAmount);
Long chargeId = chargeResult.getResourceId();
// add charge after maturity
PostLoansLoanIdChargesResponse loanChargeResult = addLoanCharge(loanId, chargeId, "01 February 2023", chargeAmount);
Long loanChargeId = loanChargeResult.getResourceId();
// verify N+1 installment in schedule
verifyRepaymentSchedule(loanId, //
installment(0.0, null, "01 January 2023"), //
installment(1000.0, 0.0, 0.0, 1000.0, false, "31 January 2023"), //
installment(0.0, 0.0, 100.0, 100.0, false, "01 February 2023") //
);
// waive charge
waiveLoanCharge(loanId, loanChargeId, 2);
});
runAt("03 February 2023", () -> {
Long loanId = appliedLoanId.get();
// repay loan
addRepaymentForLoan(loanId, amount, "03 February 2023");
// verify maturity
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertTrue(loanDetails.getStatus().getClosedObligationsMet());
// verify N+1 installment completion
verifyRepaymentSchedule(loanId, //
installment(0.0, null, "01 January 2023"), //
installment(1000.0, 0.0, 0.0, 0.0, true, "31 January 2023"), //
installment(0.0, 0.0, 100.0, 0.0, true, "01 February 2023") //
);
// verify obligationsMetOnDate for N+1 installment
LocalDate obligationsMetOnDate = Streams.findLast(loanDetails.getRepaymentSchedule().getPeriods().stream()).get()
.getObligationsMetOnDate();
LocalDate expected = LocalDate.of(2023, 2, 1);
assertEquals(expected, obligationsMetOnDate);
});
}
}
@@ -48,6 +48,7 @@ public class LoanProductTestBuilder {
public static final String INTEREST_PRINCIPAL_PENALTIES_FEES_ORDER_STRATEGY = "interest-principal-penalties-fees-order-strategy";
public static final String DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY = "due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest-strategy";
public static final String DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY = "due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee-strategy";
public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy";
// private static final String HEAVENS_FAMILY_STRATEGY ="heavensfamily-strategy";
// private static final String CREO_CORE_STRATEGY ="creocore-strategy";