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 27ce077c6..1a9835b8c 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 @@ -876,7 +876,9 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom { for (LoanTransactionToRepaymentScheduleMapping repaymentScheduleMapping : this.loanTransactionToRepaymentScheduleMappings) { if (updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId() != null && repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate() - .equals(updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate())) { + .equals(updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate()) + && updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId() + .equals(repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId())) { repaymentScheduleMapping.setComponents(updatedrepaymentScheduleMapping.getPrincipalPortion(), updatedrepaymentScheduleMapping.getInterestPortion(), updatedrepaymentScheduleMapping.getFeeChargesPortion(), updatedrepaymentScheduleMapping.getPenaltyChargesPortion()); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanMultipleDisbursementRepaymentScheduleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanMultipleDisbursementRepaymentScheduleTest.java new file mode 100644 index 000000000..7ebf448ec --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanMultipleDisbursementRepaymentScheduleTest.java @@ -0,0 +1,234 @@ +/** + * 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.assertNotNull; +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.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; +import org.apache.fineract.integrationtests.common.SchedulerJobHelper; +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.PeriodicAccrualAccountingHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(LoanTestLifecycleExtension.class) +public class LoanMultipleDisbursementRepaymentScheduleTest { + + private static final DateTimeFormatter DATE_FORMATTER = new DateTimeFormatterBuilder().appendPattern("dd MMMM yyyy").toFormatter(); + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + private LoanTransactionHelper loanTransactionHelper; + private ClientHelper clientHelper; + private PeriodicAccrualAccountingHelper periodicAccrualAccountingHelper; + private AccountHelper accountHelper; + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec); + this.clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); + this.periodicAccrualAccountingHelper = new PeriodicAccrualAccountingHelper(this.requestSpec, this.responseSpec); + this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); + } + + @Test + public void loanNoDuplicateRepaymentScheduleWithMultipleDisbursementTest() { + try { + final SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(requestSpec); + // Accounts oof periodic accrual + final Account assetAccount = this.accountHelper.createAssetAccount(); + final Account incomeAccount = this.accountHelper.createIncomeAccount(); + final Account expenseAccount = this.accountHelper.createExpenseAccount(); + final Account overpaymentAccount = this.accountHelper.createLiabilityAccount(); + + // Set business date + LocalDate currentDate = LocalDate.of(2023, 07, 7); + + GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.TRUE); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, currentDate); + + // Loan ExternalId + String loanExternalIdStr = UUID.randomUUID().toString(); + + // Client and Loan account creation + + final Integer clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue(); + final GetLoanProductsProductIdResponse getLoanProductsProductResponse = createLoanProductMultipleDisbursements( + loanTransactionHelper, assetAccount, incomeAccount, expenseAccount, overpaymentAccount); + assertNotNull(getLoanProductsProductResponse); + + final Integer loanId = createLoanAccountAndDisbursePartialAmount(clientId, getLoanProductsProductResponse.getId().intValue(), + loanExternalIdStr); + + // Add Charge fee + Integer fee = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "5.15", false)); + + LocalDate targetDate = LocalDate.of(2023, 7, 11); + final String feeCharge1AddedDate = DATE_FORMATTER.format(targetDate); + + Integer feeLoanChargeId = this.loanTransactionHelper.addChargesForLoan(loanId, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(fee), feeCharge1AddedDate, "5.15")); + + assertNotNull(feeLoanChargeId); + + // run cob + + currentDate = LocalDate.of(2023, 07, 12); + + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, currentDate); + + final String jobName = "Loan COB"; + schedulerJobHelper.executeAndAwaitJob(jobName); + + // verify accrual transaction created for charge due date + checkAccrualTransaction(targetDate, 0.0f, 5.15f, 0.0f, loanId); + + // make Merchant Issued Refund + + currentDate = LocalDate.of(2023, 07, 21); + + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, currentDate); + + final PostLoansLoanIdTransactionsResponse merchantIssuedRefund_1 = loanTransactionHelper.makeMerchantIssuedRefund((long) loanId, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("21 July 2023").locale("en") + .transactionAmount(167.4)); + + assertNotNull(merchantIssuedRefund_1); + + // run cob + schedulerJobHelper.executeAndAwaitJob(jobName); + + // make another disbursement + currentDate = LocalDate.of(2023, 07, 24); + + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, currentDate); + + loanTransactionHelper.disburseLoanWithTransactionAmount("24 July 2023", loanId, "18"); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + + List periods = loanDetails.getRepaymentSchedule().getPeriods(); + + assertNotNull(periods); + + // verify only one active period + long activePeriods = periods.stream().filter(p -> p.getPeriod() != null && p.getPeriod().equals(1)).count(); + + assertEquals(1, activePeriods); + + } finally { + GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.FALSE); + } + + } + + private void checkAccrualTransaction(final LocalDate transactionDate, final Float interestPortion, final Float feePortion, + final Float penaltyPortion, final Integer loanID) { + + ArrayList transactions = (ArrayList) loanTransactionHelper.getLoanTransactions(this.requestSpec, + this.responseSpec, loanID); + boolean isTransactionFound = false; + for (int i = 0; i < transactions.size(); i++) { + HashMap transactionType = (HashMap) transactions.get(i).get("type"); + boolean isAccrualTransaction = (Boolean) transactionType.get("accrual"); + + if (isAccrualTransaction) { + ArrayList accrualEntryDateAsArray = (ArrayList) transactions.get(i).get("date"); + LocalDate accrualEntryDate = LocalDate.of(accrualEntryDateAsArray.get(0), accrualEntryDateAsArray.get(1), + accrualEntryDateAsArray.get(2)); + + if (transactionDate.isEqual(accrualEntryDate)) { + isTransactionFound = true; + assertEquals(interestPortion, Float.valueOf(String.valueOf(transactions.get(i).get("interestPortion"))), + "Mismatch in transaction amounts"); + assertEquals(feePortion, Float.valueOf(String.valueOf(transactions.get(i).get("feeChargesPortion"))), + "Mismatch in transaction amounts"); + assertEquals(penaltyPortion, Float.valueOf(String.valueOf(transactions.get(i).get("penaltyChargesPortion"))), + "Mismatch in transaction amounts"); + break; + } + } + } + assertTrue(isTransactionFound, "No Accrual entries are posted"); + } + + private GetLoanProductsProductIdResponse createLoanProductMultipleDisbursements(final LoanTransactionHelper loanTransactionHelper, + final Account... accounts) { + + final String loanProductJSON = new LoanProductTestBuilder().withPrincipal("1000").withRepaymentTypeAsMonth() + .withRepaymentAfterEvery("1").withNumberOfRepayments("1").withRepaymentTypeAsMonth().withinterestRatePerPeriod("0") + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsDecliningBalance() + .withAccountingRulePeriodicAccrual(accounts).withInterestCalculationPeriodTypeAsRepaymentPeriod(true).withDaysInMonth("30") + .withDaysInYear("365").withMoratorium("0", "0").withMultiDisburse().withDisallowExpectedDisbursements(true).build(null); + final Integer loanProductId = loanTransactionHelper.getLoanProductId(loanProductJSON); + return loanTransactionHelper.getLoanProduct(loanProductId); + } + + private Integer createLoanAccountAndDisbursePartialAmount(final Integer clientID, final Integer loanProductID, + final String externalId) { + + String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("1000").withLoanTermFrequency("30") + .withLoanTermFrequencyAsDays().withNumberOfRepayments("1").withRepaymentEveryAfter("30").withRepaymentFrequencyTypeAsDays() + .withInterestRatePerPeriod("0").withInterestTypeAsFlatBalance().withAmortizationTypeAsEqualPrincipalPayments() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withExpectedDisbursementDate("07 July 2023") + .withSubmittedOnDate("07 July 2023").withLoanType("individual").withExternalId(externalId) + .build(clientID.toString(), loanProductID.toString(), null); + + final Integer loanId = loanTransactionHelper.getLoanId(loanApplicationJSON); + loanTransactionHelper.approveLoan("07 July 2023", "1000", loanId, null); + loanTransactionHelper.disburseLoanWithTransactionAmount("07 July 2023", loanId, "370.55"); + return loanId; + } + +}