diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index 91ba44c03..39a4603ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -1267,6 +1267,19 @@ final class LoansApiResourceSwagger { public BigDecimal disbursedAmountPercentageForDownPayment; @Schema(example = "false") public Boolean enableAutoRepaymentForDownPayment; + + public List charges; + + static final class PostLoansRequestChargeData { + + private PostLoansRequestChargeData() {} + + @Schema(example = "1") + public Long chargeId; + + @Schema(example = "1.0") + public BigDecimal amount; + } } @Schema(description = "PostLoansResponse") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index cc5678f6f..9a03fdfb5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -152,6 +152,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.GroupLoanIndividualMonit import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy; import org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement; import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails; import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent; @@ -330,8 +331,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPreBusinessEvent(new LoanDisbursalBusinessEvent(loan)); - final List existingTransactionIds = new ArrayList<>(); - final List existingReversedTransactionIds = new ArrayList<>(); + List existingTransactionIds = new ArrayList<>(); + List existingReversedTransactionIds = new ArrayList<>(); final AppUser currentUser = getAppUserIfPresent(); final Map changes = new LinkedHashMap<>(); @@ -489,6 +490,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); } + existingTransactionIds = new ArrayList<>(loan.findExistingTransactionIds()); + existingReversedTransactionIds = new ArrayList<>(loan.findExistingReversedTransactionIds()); final Set loanCharges = loan.getActiveCharges(); final Map disBuLoanCharges = new HashMap<>(); for (final LoanCharge loanCharge : loanCharges) { @@ -499,10 +502,17 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf if (loanCharge.isDisbursementCharge()) { LoanTransaction loanTransaction = LoanTransaction.accrueTransaction(loan, loan.getOffice(), actualDisbursementDate, loanCharge.amount(), null, loanCharge.amount(), null, externalIdFactory.create()); + loanTransaction.updateLoan(loan); + LoanChargePaidBy loanChargePaidBy = new LoanChargePaidBy(loanTransaction, loanCharge, + loanCharge.getAmount(loan.getCurrency()).getAmount(), 1); + loanTransaction.getLoanChargesPaid().add(loanChargePaidBy); + loan.addLoanTransaction(loanTransaction); LoanTransaction savedLoanTransaction = loanTransactionRepository.saveAndFlush(loanTransaction); businessEventNotifierService.notifyPostBusinessEvent(new LoanAccrualTransactionCreatedBusinessEvent(savedLoanTransaction)); } } + postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + for (final Map.Entry entrySet : disBuLoanCharges.entrySet()) { final PortfolioAccountData savingAccountData = this.accountAssociationsReadPlatformService.retriveLoanLinkedAssociation(loanId); final SavingsAccount fromSavingsAccount = null; 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 73f0778bb..2ebf257e3 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 @@ -18,6 +18,7 @@ */ package org.apache.fineract.integrationtests; +import static java.lang.System.lineSeparator; import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; import static org.apache.fineract.integrationtests.BaseLoanIntegrationTest.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY; import static org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder.DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY; @@ -26,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; 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 static org.junit.jupiter.api.Assertions.fail; import io.restassured.builder.RequestSpecBuilder; import io.restassured.builder.ResponseSpecBuilder; @@ -39,6 +41,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -59,6 +62,7 @@ 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.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.JournalEntryTransactionItem; import org.apache.fineract.client.models.PaymentAllocationOrder; import org.apache.fineract.client.models.PostChargesResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; @@ -641,6 +645,9 @@ public abstract class BaseLoanIntegrationTest { loanTransactionHelper.undoLastDisbursalLoan(loanId, new PostLoansLoanIdRequest()); } + // Note: this is buggy because if multiple journal entries are for the same account, amount and type, the + // verification will pass + // not all journal entries have been validated - since there might be duplicates protected void verifyJournalEntries(Long loanId, Journal... entries) { GetJournalEntriesTransactionIdResponse journalEntriesForLoan = journalEntryHelper.getJournalEntriesForLoan(loanId); Assertions.assertEquals(entries.length, journalEntriesForLoan.getPageItems().size()); @@ -653,6 +660,27 @@ public abstract class BaseLoanIntegrationTest { }); } + protected void verifyJournalEntriesSequentially(Long loanId, Journal... entries) { + GetJournalEntriesTransactionIdResponse journalEntriesForLoan = journalEntryHelper.getJournalEntriesForLoan(loanId); + List sortedJournalEntries = journalEntriesForLoan.getPageItems().stream() + .sorted(Comparator.comparing(JournalEntryTransactionItem::getId)).toList(); + for (int i = 0; i < entries.length && i < journalEntriesForLoan.getPageItems().size(); i++) { + Journal journalEntry = entries[i]; + JournalEntryTransactionItem item = sortedJournalEntries.get(i); + boolean found = Objects.equals(item.getAmount(), journalEntry.amount) + && Objects.equals(item.getGlAccountId(), journalEntry.account.getAccountID().longValue()) + && Objects.requireNonNull(item.getEntryType()).getValue().equals(journalEntry.type); + assertTrue(found, "Journal entry mismatch at position " + i + "." + lineSeparator() + "Wanted Journal entry: " + journalEntry + + lineSeparator() + "Actual Journal entry: " + item); + } + if (journalEntriesForLoan.getPageItems().size() > entries.length) { + fail("Some Journal Entries are not verified. The missing entries are here: " + + sortedJournalEntries.subList(entries.length, sortedJournalEntries.size())); + } + Assertions.assertEquals(entries.length, journalEntriesForLoan.getPageItems().size(), + "There were more journal entries expected than actually present."); + } + protected void verifyTRJournalEntries(Long transactionId, Journal... entries) { GetJournalEntriesTransactionIdResponse journalEntriesForLoan = journalEntryHelper.getJournalEntries("L" + transactionId.toString()); Assertions.assertEquals(entries.length, journalEntriesForLoan.getPageItems().size()); @@ -675,6 +703,13 @@ public abstract class BaseLoanIntegrationTest { return loanChargeId.longValue(); } + protected Long createDisbursementPercentageCharge(double percentageAmount) { + Integer chargeId = ChargesHelper.createCharges(requestSpec, responseSpec, ChargesHelper + .getLoanDisbursementJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_PERCENTAGE_AMOUNT, String.valueOf(percentageAmount))); + assertNotNull(chargeId); + return chargeId.longValue(); + } + protected void verifyRepaymentSchedule(Long loanId, Installment... installments) { GetLoansLoanIdResponse loanResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue()); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN); @@ -1192,6 +1227,11 @@ public abstract class BaseLoanIntegrationTest { public static final Integer FLAT = 1; } + public static class InterestRecalculationCompoundingMethod { + + public static final Integer NONE = 0; + } + public static class RepaymentFrequencyType { public static final Integer MONTHS = 2; @@ -1227,6 +1267,7 @@ public abstract class BaseLoanIntegrationTest { public static class RescheduleStrategyMethod { + public static final Integer REDUCE_EMI_AMOUNT = 3; public static final Integer ADJUST_LAST_UNPAID_PERIOD = 4; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/accounting/Account.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/accounting/Account.java index 936fee0a5..3dd80e8a9 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/accounting/Account.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/accounting/Account.java @@ -18,6 +18,9 @@ */ package org.apache.fineract.integrationtests.common.accounting; +import lombok.ToString; + +@ToString public class Account { public enum AccountType { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/repayment/LoanRepaymentTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/repayment/LoanRepaymentTest.java new file mode 100644 index 000000000..f29fcebed --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/repayment/LoanRepaymentTest.java @@ -0,0 +1,133 @@ +/** + * 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.loan.repayment; + +import java.math.BigDecimal; +import java.util.List; +import org.apache.fineract.client.models.ChargeData; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PostLoansRequestChargeData; +import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.integrationtests.BaseLoanIntegrationTest; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.junit.jupiter.api.Test; + +public class LoanRepaymentTest extends BaseLoanIntegrationTest { + + @Test + public void test_LoanRepaymentWorks_WhenDisbursementChargeIsAvailable_AndAccrualAccounting_AndDailyRecalculateInterest_AndDailyInterestCalculationPeriod() { + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 1; + + // Create charges + double charge1Amount = 1.0; + double charge2Amount = 1.5; + Long charge1Id = createDisbursementPercentageCharge(charge1Amount); + Long charge2Id = createDisbursementPercentageCharge(charge2Amount); + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null)// + .charges(List.of(new ChargeData().id(charge1Id), new ChargeData().id(charge2Id)));// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 1000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .charges(List.of(// + new PostLoansRequestChargeData().chargeId(charge1Id).amount(BigDecimal.valueOf(charge1Amount)), // + new PostLoansRequestChargeData().chargeId(charge2Id).amount(BigDecimal.valueOf(charge2Amount))// + ));// + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + Long loanId = approvedLoanResult.getLoanId(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(1000.0, "Disbursement", "01 January 2023"), // + transaction(25.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(10.0, "Accrual", "01 January 2023"), // + transaction(15.0, "Accrual", "01 January 2023") // + ); + + // verify journal entries + verifyJournalEntries(loanId, // + journalEntry(1000.0, loansReceivableAccount, "DEBIT"), // + journalEntry(1000.0, fundSource, "CREDIT"), // + journalEntry(25.0, feeIncomeAccount, "CREDIT"), // + journalEntry(25.0, fundSource, "DEBIT"), // + journalEntry(10.0, feeReceivableAccount, "DEBIT"), // + journalEntry(10.0, feeIncomeAccount, "CREDIT"), // + journalEntry(15.0, feeReceivableAccount, "DEBIT"), // + journalEntry(15.0, feeIncomeAccount, "CREDIT") // + ); + + // repay 500 + addRepaymentForLoan(loanId, 500.0, "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(1000.0, "Disbursement", "01 January 2023"), // + transaction(25.0, "Repayment (at time of disbursement)", "01 January 2023"), // + transaction(500.0, "Repayment", "01 January 2023") // + ); + }); + } +}