FINERACT-1958

- reverse handling of disbursement with down payment
This commit is contained in:
Peter Bagrij
2023-09-11 16:29:08 +02:00
committed by Adam Saghy
parent 3a092590e9
commit 6d3357e740
7 changed files with 2092 additions and 13 deletions
@@ -3044,7 +3044,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
? Boolean.FALSE
: Boolean.TRUE;
this.loanRepaymentScheduleDetail.setPrincipal(this.approvedPrincipal);
if (this.loanProduct.isMultiDisburseLoan()) {
// Remove All the Disbursement Details If the Loan Product is disabled and exists one
if (this.loanProduct().isDisallowExpectedDisbursements() && !getDisbursementDetails().isEmpty()) {
for (LoanDisbursementDetails disbursementDetail : getAllDisbursementDetails()) {
disbursementDetail.reverse();
}
} else {
for (final LoanDisbursementDetails details : getDisbursementDetails()) {
details.updateActualDisbursementDate(null);
}
@@ -5535,6 +5540,16 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
}
public ChangedTransactionDetail recalculateScheduleFromLastTransaction(final ScheduleGeneratorDTO generatorDTO) {
if (this.repaymentScheduleDetail().isInterestRecalculationEnabled()) {
regenerateRepaymentScheduleWithInterestRecalculation(generatorDTO);
} else {
regenerateRepaymentSchedule(generatorDTO);
}
return processTransactions();
}
public ChangedTransactionDetail handleRegenerateRepaymentScheduleWithInterestRecalculation(final ScheduleGeneratorDTO generatorDTO) {
regenerateRepaymentScheduleWithInterestRecalculation(generatorDTO);
return processTransactions();
@@ -6505,7 +6520,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
}
}
reverseExistingTransactionsTillLastDisbursal(lastDisbursalTransaction);
loan.recalculateScheduleFromLastTransaction(scheduleGeneratorDTO, existingTransactionIds, existingReversedTransactionIds);
loan.recalculateScheduleFromLastTransaction(scheduleGeneratorDTO);
actualChanges.put("undolastdisbursal", "true");
actualChanges.put("disbursedAmount", this.getDisbursedAmount());
updateLoanSummaryDerivedFields();
@@ -6529,6 +6544,28 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
transaction.reverse();
}
}
if (isAutoRepaymentForDownPaymentEnabled()) {
// identify down-payment amount for the transaction
BigDecimal disbursedAmountPercentageForDownPayment = this.loanRepaymentScheduleDetail
.getDisbursedAmountPercentageForDownPayment();
Money downPaymentMoney = Money.of(getCurrency(),
MathUtil.percentageOf(lastDisbursalTransaction.getAmount(), disbursedAmountPercentageForDownPayment, 19));
// find the matching down-payment transaction based on date, amount and it also must have downpayment
// installment linked
Optional<LoanTransaction> downPaymentTransaction = this.loanTransactions.stream()
.filter(tr -> tr.getTransactionDate().equals(lastDisbursalTransaction.getTransactionDate())
&& hasAnInstallmentWithDownPayment(tr, downPaymentMoney.getAmount()))
.max(Comparator.comparing(LoanTransaction::getId));
// reverse the down-payment transaction
downPaymentTransaction.ifPresent(LoanTransaction::reverse);
}
}
private boolean hasAnInstallmentWithDownPayment(LoanTransaction tr, BigDecimal amount) {
return tr.getAmount().compareTo(amount) == 0 && tr.getLoanTransactionToRepaymentScheduleMappings().stream()
.anyMatch(mapping -> mapping.getLoanRepaymentScheduleInstallment().isDownPayment());
}
private void updateLoanToLastDisbursalState(LoanDisbursementDetails disbursementDetail) {
@@ -910,7 +910,7 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
}
public Boolean isAllowTypeTransactionAtTheTimeOfLastUndo() {
return isDisbursement() || isAccrual() || isRepaymentAtDisbursement();
return isDisbursement() || isAccrual() || isRepaymentAtDisbursement() || isRepayment();
}
public boolean isAccrualTransaction() {
@@ -869,16 +869,6 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final Map<String, Object> accountingBridgeData = loan.deriveAccountingBridgeData(currency.getCode(), existingTransactionIds,
existingReversedTransactionIds, isAccountTransfer);
journalEntryWritePlatformService.createJournalEntriesForLoan(accountingBridgeData);
// Remove All the Disbursement Details If the Loan Product is disabled and exists one
if (loan.loanProduct().isDisallowExpectedDisbursements() && !loan.getDisbursementDetails().isEmpty()) {
List<LoanDisbursementDetails> reversedDisbursementDetails = new ArrayList<>();
for (LoanDisbursementDetails disbursementDetail : loan.getAllDisbursementDetails()) {
disbursementDetail.reverse();
reversedDisbursementDetails.add(disbursementDetail);
}
this.loanDisbursementDetailsRepository.saveAllAndFlush(reversedDisbursementDetails);
}
loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds);
businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoDisbursalBusinessEvent(loan));
}
@@ -0,0 +1,338 @@
/**
* 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 java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE;
import static org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder.DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Collections;
import java.util.Objects;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.ToString;
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.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.client.util.CallFailedRuntimeException;
import org.apache.fineract.integrationtests.common.BusinessDateHelper;
import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper;
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.loans.LoanProductHelper;
import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.junit.jupiter.api.Assertions;
public abstract class BaseLoanIntegrationTest {
static {
Utils.initializeRESTAssured();
}
protected static final String DATETIME_PATTERN = "dd MMMM yyyy";
protected final ResponseSpecification requestSpec = createResponseSpecification(200);
protected final RequestSpecification responseSpec = createRequestSpecification();
protected final AccountHelper accountHelper = new AccountHelper(responseSpec, requestSpec);
protected final LoanTransactionHelper loanTransactionHelper = new LoanTransactionHelper(responseSpec, requestSpec);
protected final LoanProductHelper loanProductHelper = new LoanProductHelper();
protected JournalEntryHelper journalEntryHelper = new JournalEntryHelper(responseSpec, requestSpec);
protected BusinessDateHelper businessDateHelper = new BusinessDateHelper();
// asset
protected final Account loansReceivableAccount = accountHelper.createAssetAccount();
protected final Account interestFeeReceivableAccount = accountHelper.createAssetAccount();
protected final Account suspenseAccount = accountHelper.createAssetAccount();
// liability
protected final Account suspenseClearingAccount = accountHelper.createLiabilityAccount();
protected final Account overpaymentAccount = accountHelper.createLiabilityAccount();
// income
protected final Account interestIncomeAccount = accountHelper.createIncomeAccount();
protected final Account feeIncomeAccount = accountHelper.createIncomeAccount();
protected final Account feeChargeOffAccount = accountHelper.createIncomeAccount();
protected final Account recoveriesAccount = accountHelper.createIncomeAccount();
protected final Account interestIncomeChargeOffAccount = accountHelper.createIncomeAccount();
// expense
protected final Account creditLossBadDebtAccount = accountHelper.createExpenseAccount();
protected final Account creditLossBadDebtFraudAccount = accountHelper.createExpenseAccount();
protected final Account writtenOffAccount = accountHelper.createExpenseAccount();
protected final Account goodwillExpenseAccount = accountHelper.createExpenseAccount();
// Loan product with proper accounting setup
protected PostLoanProductsRequest createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() {
return new PostLoanProductsRequest().name(Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_", 6))//
.shortName(Utils.uniqueRandomStringGenerator("", 4))//
.description("Loan Product Description")//
.includeInBorrowerCycle(false)//
.currencyCode("USD")//
.digitsAfterDecimal(2)//
.inMultiplesOf(0)//
.installmentAmountInMultiplesOf(1)//
.useBorrowerCycle(false)//
.minPrincipal(100.0)//
.principal(1000.0)//
.maxPrincipal(10000.0)//
.minNumberOfRepayments(1)//
.numberOfRepayments(1)//
.maxNumberOfRepayments(30)//
.isLinkedToFloatingInterestRates(false)//
.minInterestRatePerPeriod((double) 0)//
.interestRatePerPeriod((double) 0)//
.maxInterestRatePerPeriod((double) 0)//
.interestRateFrequencyType(2)//
.repaymentEvery(30)//
.repaymentFrequencyType(0)//
.amortizationType(1)//
.interestType(0)//
.isEqualAmortization(false)//
.interestCalculationPeriodType(1)//
.transactionProcessingStrategyCode(
LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY)//
.daysInYearType(1)//
.daysInMonthType(1)//
.canDefineInstallmentAmount(true)//
.graceOnArrearsAgeing(3)//
.overdueDaysForNPA(179)//
.accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
.principalThresholdForLastInstallment(50)//
.allowVariableInstallments(false)//
.canUseForTopup(false)//
.isInterestRecalculationEnabled(false)//
.holdGuaranteeFunds(false)//
.multiDisburseLoan(true)//
.allowAttributeOverrides(new AllowAttributeOverrides()//
.amortizationType(true)//
.interestType(true)//
.transactionProcessingStrategyCode(true)//
.interestCalculationPeriodType(true)//
.inArrearsTolerance(true)//
.repaymentEvery(true)//
.graceOnPrincipalAndInterestPayment(true)//
.graceOnArrearsAgeing(true))//
.allowPartialPeriodInterestCalcualtion(true)//
.maxTrancheCount(10)//
.outstandingLoanBalance(10000.0)//
.charges(Collections.emptyList())//
.accountingRule(3)//
.fundSourceAccountId(suspenseClearingAccount.getAccountID().longValue())//
.loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue())//
.transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())//
.interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue())//
.incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue())//
.incomeFromPenaltyAccountId(feeIncomeAccount.getAccountID().longValue())//
.incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue())//
.writeOffAccountId(writtenOffAccount.getAccountID().longValue())//
.overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())//
.receivableInterestAccountId(interestFeeReceivableAccount.getAccountID().longValue())//
.receivableFeeAccountId(interestFeeReceivableAccount.getAccountID().longValue())//
.receivablePenaltyAccountId(interestFeeReceivableAccount.getAccountID().longValue())//
.dateFormat(DATETIME_PATTERN)//
.locale("en_GB")//
.disallowExpectedDisbursements(true)//
.allowApprovedDisbursedAmountsOverApplied(true)//
.overAppliedCalculationType("percentage")//
.overAppliedNumber(50)//
.goodwillCreditAccountId(goodwillExpenseAccount.getAccountID().longValue())//
.incomeFromGoodwillCreditInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())//
.incomeFromGoodwillCreditFeesAccountId(feeChargeOffAccount.getAccountID().longValue())//
.incomeFromGoodwillCreditPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue())//
.incomeFromChargeOffInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())//
.incomeFromChargeOffFeesAccountId(feeChargeOffAccount.getAccountID().longValue())//
.chargeOffExpenseAccountId(creditLossBadDebtAccount.getAccountID().longValue())//
.chargeOffFraudExpenseAccountId(creditLossBadDebtFraudAccount.getAccountID().longValue())//
.incomeFromChargeOffPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue());
}
private static RequestSpecification createRequestSpecification() {
RequestSpecification request = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
request.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
request.header("Fineract-Platform-TenantId", "default");
return request;
}
private static ResponseSpecification createResponseSpecification(int statusCode) {
return new ResponseSpecBuilder().expectStatusCode(statusCode).build();
}
protected void verifyUndoLastDisbursalShallFail(Long loanId, String expectedError) {
ResponseSpecification errorResponse = new ResponseSpecBuilder().expectStatusCode(403).build();
LoanTransactionHelper validationErrorHelper = new LoanTransactionHelper(this.responseSpec, errorResponse);
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, () -> {
validationErrorHelper.undoLastDisbursalLoan(loanId, new PostLoansLoanIdRequest());
});
assertTrue(exception.getMessage().contains(expectedError));
}
protected void verifyNoTransactions(Long loanId) {
verifyTransactions(loanId);
}
protected void verifyTransactions(Long loanId, Transaction... transactions) {
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(responseSpec, requestSpec, loanId.intValue());
if (transactions == null || transactions.length == 0) {
assertNull(loanDetails.getTransactions(), "No transaction is expected");
} else {
Assertions.assertEquals(transactions.length, loanDetails.getTransactions().size());
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN);
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)));
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"));
}
protected void verifyJournalEntries(Long loanId, JournalEntry... entries) {
GetJournalEntriesTransactionIdResponse journalEntriesForLoan = journalEntryHelper.getJournalEntriesForLoan(loanId);
Assertions.assertEquals(entries.length, journalEntriesForLoan.getPageItems().size());
Arrays.stream(entries).forEach(journalEntry -> {
boolean found = journalEntriesForLoan.getPageItems().stream()
.anyMatch(item -> Objects.equals(item.getAmount(), journalEntry.amount)
&& Objects.equals(item.getGlAccountId(), journalEntry.account.getAccountID().longValue())
&& Objects.requireNonNull(item.getEntryType()).getValue().equals(journalEntry.type));
Assertions.assertTrue(found, "Required journal entry not found: " + journalEntry);
});
}
protected void verifyRepaymentSchedule(Long loanId, Installment... installments) {
GetLoansLoanIdResponse loanResponse = loanTransactionHelper.getLoan(responseSpec, requestSpec, loanId.intValue());
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN);
Assertions.assertNotNull(loanResponse.getRepaymentSchedule());
Assertions.assertNotNull(loanResponse.getRepaymentSchedule().getPeriods());
Assertions.assertEquals(installments.length, loanResponse.getRepaymentSchedule().getPeriods().size(),
"Expected installments are not matching with the installments configured on the loan");
for (int i = 1; i < installments.length; i++) {
if (installments[i].completed == null) { // this is for the disbursement
Assertions.assertEquals(installments[i].amount,
loanResponse.getRepaymentSchedule().getPeriods().get(i).getPrincipalLoanBalanceOutstanding());
} else {
Assertions.assertEquals(installments[i].amount, loanResponse.getRepaymentSchedule().getPeriods().get(i).getPrincipalDue());
}
Assertions.assertEquals(installments[i].completed, loanResponse.getRepaymentSchedule().getPeriods().get(i).getComplete());
Assertions.assertEquals(LocalDate.parse(installments[i].dueDate, dateTimeFormatter),
loanResponse.getRepaymentSchedule().getPeriods().get(i).getDueDate());
}
}
protected void runAt(String date, Runnable runnable) {
try {
GlobalConfigurationHelper.updateEnabledFlagForGlobalConfiguration(responseSpec, requestSpec, 42, true);
GlobalConfigurationHelper.updateIsBusinessDateEnabled(responseSpec, requestSpec, TRUE);
businessDateHelper.updateBusinessDate(
new BusinessDateRequest().type(BUSINESS_DATE.getName()).date(date).dateFormat(DATETIME_PATTERN).locale("en"));
runnable.run();
} finally {
GlobalConfigurationHelper.updateIsBusinessDateEnabled(responseSpec, requestSpec, FALSE);
GlobalConfigurationHelper.updateEnabledFlagForGlobalConfiguration(responseSpec, requestSpec, 42, false);
}
}
protected Long applyAndApproveLoan(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount) {
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(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(1).loanTermFrequency(30).loanTermFrequencyType(0).maxOutstandingLoanBalance(BigDecimal.valueOf(amount))
.principal(BigDecimal.valueOf(amount)).loanType("individual"));
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
new PostLoansLoanIdRequest().approvedLoanAmount(BigDecimal.valueOf(amount)).dateFormat(DATETIME_PATTERN)
.approvedOnDate("01 January 2023").locale("en"));
return approvedLoanResult.getLoanId();
}
protected void addRepaymentForLoan(Long loanId, Double amount, String date) {
String firstRepaymentUUID = UUID.randomUUID().toString();
loanTransactionHelper.makeLoanRepayment(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN)
.transactionDate(date).locale("en").transactionAmount(amount).externalId(firstRepaymentUUID));
}
protected JournalEntry journalEntry(double amount, Account account, String type) {
return new JournalEntry(amount, account, type);
}
protected Transaction transaction(double amount, String type, String date) {
return new Transaction(amount, type, date);
}
protected Installment installment(double amount, Boolean completed, String dueDate) {
return new Installment(amount, completed, dueDate);
}
@ToString
@AllArgsConstructor
public static class Transaction {
Double amount;
String type;
String date;
}
@ToString
@AllArgsConstructor
public static class JournalEntry {
Double amount;
Account account;
String type;
}
@ToString
@AllArgsConstructor
public static class Installment {
Double amount;
Boolean completed;
String dueDate;
}
}
@@ -118,4 +118,13 @@ public class JournalEntryHelper {
return GSON.fromJson(response, GetJournalEntriesTransactionIdResponse.class);
}
public GetJournalEntriesTransactionIdResponse getJournalEntriesForLoan(final Long loanId) {
log.info("Getting GL Journal entries for loan id {}", loanId);
final String url = "/fineract-provider/api/v1/journalentries?loanId=" + loanId + "&tenantIdentifier=default"
+ "&orderBy=id&sortOrder=desc&locale=en&dateFormat=dd MMMM yyyy";
final String response = Utils.performServerGet(this.requestSpec, this.responseSpec, url, null);
log.info("response {}", response);
return GSON.fromJson(response, GetJournalEntriesTransactionIdResponse.class);
}
}
@@ -38,6 +38,10 @@ public class LoanProductHelper extends IntegrationTest {
return ok(fineract().loanProducts.retrieveLoanProductDetails1(externalId));
}
public GetLoanProductsProductIdResponse retrieveLoanProductById(Long loanProductId) {
return ok(fineract().loanProducts.retrieveLoanProductDetails(loanProductId));
}
public PutLoanProductsProductIdResponse updateLoanProductByExternalId(String externalId, PutLoanProductsProductIdRequest request) {
return ok(fineract().loanProducts.updateLoanProduct1(externalId, request));
}