diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java index 4df78107f..93a70ed50 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java @@ -20,19 +20,27 @@ package org.apache.fineract.test.stepdef.loan; import static org.assertj.core.api.Assertions.assertThat; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.cucumber.datatable.DataTable; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; import java.io.IOException; import java.lang.reflect.Method; import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import okhttp3.ResponseBody; +import org.apache.fineract.client.models.LoanScheduleData; +import org.apache.fineract.client.models.LoanSchedulePeriodData; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.PostLoansResponse; @@ -41,22 +49,23 @@ import org.apache.fineract.test.factory.LoanRequestFactory; import org.apache.fineract.test.helper.ErrorHelper; import org.apache.fineract.test.helper.ErrorMessageHelper; import org.apache.fineract.test.helper.ErrorResponse; +import org.apache.fineract.test.helper.Utils; import org.apache.fineract.test.messaging.EventAssertion; import org.apache.fineract.test.messaging.event.loan.LoanReAgeEvent; import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; import org.junit.jupiter.api.Assertions; -import org.springframework.beans.factory.annotation.Autowired; import retrofit2.Response; @Slf4j +@RequiredArgsConstructor public class LoanReAgingStepDef extends AbstractStepDef { - @Autowired - private LoanTransactionsApi loanTransactionsApi; + private static final String DATE_FORMAT = "dd MMMM yyyy"; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT); - @Autowired - private EventAssertion eventAssertion; + private final LoanTransactionsApi loanTransactionsApi; + private final EventAssertion eventAssertion; @When("Admin creates a Loan re-aging transaction with the following data:") public void createReAgingTransaction(DataTable table) throws IOException { @@ -302,4 +311,187 @@ public class LoanReAgingStepDef extends AbstractStepDef { assertThat(errorDetails.getHttpStatusCode()).as(ErrorMessageHelper.dateFailureErrorCodeMsg()).isEqualTo(403); assertThat(developerMessage).matches(ErrorMessageHelper.reAgeContractTerminatedLoanFailure()); } + + @When("Admin creates a Loan re-aging preview with the following data:") + public void createReAgingPreview(DataTable table) throws IOException { + Response loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.body().getLoanId(); + + List data = table.asLists().get(1); + int frequencyNumber = Integer.parseInt(data.get(0)); + String frequencyType = data.get(1); + String startDate = data.get(2); + int numberOfInstallments = Integer.parseInt(data.get(3)); + + Response response = loanTransactionsApi + .previewReAgeSchedule(loanId, frequencyNumber, frequencyType, startDate, numberOfInstallments, DATE_FORMAT, "en").execute(); + ErrorHelper.checkSuccessfulApiCall(response); + testContext().set(TestContextKey.LOAN_REAGING_PREVIEW_RESPONSE, response); + + log.info( + "Re-aging preview created for loan ID: {} with parameters: frequencyNumber={}, frequencyType={}, startDate={}, numberOfInstallments={}", + loanId, frequencyNumber, frequencyType, startDate, numberOfInstallments); + } + + @When("Admin creates a Loan re-aging preview by Loan external ID with the following data:") + public void createReAgingPreviewByLoanExternalId(DataTable table) throws IOException { + Response loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + String loanExternalId = loanResponse.body().getResourceExternalId(); + + List data = table.asLists().get(1); + int frequencyNumber = Integer.parseInt(data.get(0)); + String frequencyType = data.get(1); + String startDate = data.get(2); + int numberOfInstallments = Integer.parseInt(data.get(3)); + + Response response = loanTransactionsApi + .previewReAgeSchedule1(loanExternalId, frequencyNumber, frequencyType, startDate, numberOfInstallments, DATE_FORMAT, "en") + .execute(); + ErrorHelper.checkSuccessfulApiCall(response); + testContext().set(TestContextKey.LOAN_REAGING_PREVIEW_RESPONSE, response); + + log.info( + "Re-aging preview created for loan external ID: {} with parameters: frequencyNumber={}, frequencyType={}, startDate={}, numberOfInstallments={}", + loanExternalId, frequencyNumber, frequencyType, startDate, numberOfInstallments); + } + + @Then("Loan Repayment schedule preview has {int} periods, with the following data for periods:") + public void loanRepaymentSchedulePreviewPeriodsCheck(int linesExpected, DataTable table) { + Response scheduleResponse = testContext().get(TestContextKey.LOAN_REAGING_PREVIEW_RESPONSE); + + List repaymentPeriods = scheduleResponse.body().getPeriods(); + + Response loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + String resourceId = String.valueOf(loanResponse.body().getLoanId()); + + List> data = table.asLists(); + int nrLines = data.size(); + int linesActual = (int) repaymentPeriods.stream().filter(r -> r.getPeriod() != null).count(); + for (int i = 1; i < nrLines; i++) { + List expectedValues = data.get(i); + String dueDateExpected = expectedValues.get(2); + + List> actualValuesList = repaymentPeriods.stream() + .filter(r -> dueDateExpected.equals(FORMATTER.format(r.getDueDate()))) + .map(r -> fetchValuesOfRepaymentSchedule(data.get(0), r)).collect(Collectors.toList()); + + boolean containsExpectedValues = actualValuesList.stream().anyMatch(actualValues -> actualValues.equals(expectedValues)); + assertThat(containsExpectedValues) + .as(ErrorMessageHelper.wrongValueInLineInRepaymentSchedule(resourceId, i, actualValuesList, expectedValues)).isTrue(); + + assertThat(linesActual).as(ErrorMessageHelper.wrongNumberOfLinesInRepaymentSchedule(resourceId, linesActual, linesExpected)) + .isEqualTo(linesExpected); + } + } + + @Then("Loan Repayment schedule preview has the following data in Total row:") + public void loanRepaymentScheduleAmountCheck(DataTable table) { + List> data = table.asLists(); + List header = data.get(0); + List expectedValues = data.get(1); + Response scheduleResponse = testContext().get(TestContextKey.LOAN_REAGING_PREVIEW_RESPONSE); + validateRepaymentScheduleTotal(header, scheduleResponse.body(), expectedValues); + } + + private List fetchValuesOfRepaymentSchedule(List header, LoanSchedulePeriodData repaymentPeriod) { + List actualValues = new ArrayList<>(); + for (String headerName : header) { + switch (headerName) { + case "Nr" -> actualValues.add(repaymentPeriod.getPeriod() == null ? null : String.valueOf(repaymentPeriod.getPeriod())); + case "Days" -> + actualValues.add(repaymentPeriod.getDaysInPeriod() == null ? null : String.valueOf(repaymentPeriod.getDaysInPeriod())); + case "Date" -> + actualValues.add(repaymentPeriod.getDueDate() == null ? null : FORMATTER.format(repaymentPeriod.getDueDate())); + case "Paid date" -> actualValues.add(repaymentPeriod.getObligationsMetOnDate() == null ? null + : FORMATTER.format(repaymentPeriod.getObligationsMetOnDate())); + case "Balance of loan" -> actualValues.add(repaymentPeriod.getPrincipalLoanBalanceOutstanding() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getPrincipalLoanBalanceOutstanding().doubleValue()).format()); + case "Principal due" -> actualValues.add(repaymentPeriod.getPrincipalDue() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getPrincipalDue().doubleValue()).format()); + case "Interest" -> actualValues.add(repaymentPeriod.getInterestDue() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getInterestDue().doubleValue()).format()); + case "Fees" -> actualValues.add(repaymentPeriod.getFeeChargesDue() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getFeeChargesDue().doubleValue()).format()); + case "Penalties" -> actualValues.add(repaymentPeriod.getPenaltyChargesDue() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getPenaltyChargesDue().doubleValue()).format()); + case "Due" -> actualValues.add(repaymentPeriod.getTotalDueForPeriod() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getTotalDueForPeriod().doubleValue()).format()); + case "Paid" -> actualValues.add(repaymentPeriod.getTotalPaidForPeriod() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getTotalPaidForPeriod().doubleValue()).format()); + case "In advance" -> actualValues.add(repaymentPeriod.getTotalPaidInAdvanceForPeriod() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getTotalPaidInAdvanceForPeriod().doubleValue()).format()); + case "Late" -> actualValues.add(repaymentPeriod.getTotalPaidLateForPeriod() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getTotalPaidLateForPeriod().doubleValue()).format()); + case "Waived" -> actualValues.add(repaymentPeriod.getTotalWaivedForPeriod() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getTotalWaivedForPeriod().doubleValue()).format()); + case "Outstanding" -> actualValues.add(repaymentPeriod.getTotalOutstandingForPeriod() == null ? null + : new Utils.DoubleFormatter(repaymentPeriod.getTotalOutstandingForPeriod().doubleValue()).format()); + default -> throw new IllegalStateException(String.format("Header name %s cannot be found", headerName)); + } + } + return actualValues; + } + + @SuppressFBWarnings("SF_SWITCH_NO_DEFAULT") + private List validateRepaymentScheduleTotal(List header, LoanScheduleData repaymentSchedule, + List expectedAmounts) { + List actualValues = new ArrayList<>(); + Double paidActual = 0.0; + List periods = repaymentSchedule.getPeriods(); + for (LoanSchedulePeriodData period : periods) { + if (null != period.getTotalPaidForPeriod()) { + paidActual += period.getTotalPaidForPeriod().doubleValue(); + } + } + BigDecimal paidActualBd = new BigDecimal(paidActual).setScale(2, RoundingMode.HALF_DOWN); + + for (int i = 0; i < header.size(); i++) { + String headerName = header.get(i); + String expectedValue = expectedAmounts.get(i); + switch (headerName) { + case "Principal due" -> assertThat(repaymentSchedule.getTotalPrincipalExpected().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentSchedulePrincipal( + repaymentSchedule.getTotalPrincipalExpected().doubleValue(), Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Interest" -> assertThat(repaymentSchedule.getTotalInterestCharged().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentScheduleInterest( + repaymentSchedule.getTotalInterestCharged().doubleValue(), Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Fees" -> assertThat(repaymentSchedule.getTotalFeeChargesCharged().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentScheduleFees( + repaymentSchedule.getTotalFeeChargesCharged().doubleValue(), Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Penalties" -> assertThat(repaymentSchedule.getTotalPenaltyChargesCharged().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentSchedulePenalties( + repaymentSchedule.getTotalPenaltyChargesCharged().doubleValue(), Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Due" -> assertThat(repaymentSchedule.getTotalRepaymentExpected().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentScheduleDue( + repaymentSchedule.getTotalRepaymentExpected().doubleValue(), Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Paid" -> assertThat(paidActualBd.doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentSchedulePaid(paidActualBd.doubleValue(), + Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "In advance" -> assertThat(repaymentSchedule.getTotalPaidInAdvance().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentScheduleInAdvance( + repaymentSchedule.getTotalPaidInAdvance().doubleValue(), Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Late" -> assertThat(repaymentSchedule.getTotalPaidLate().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentScheduleLate(repaymentSchedule.getTotalPaidLate().doubleValue(), + Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Waived" -> assertThat(repaymentSchedule.getTotalWaived().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentScheduleWaived(repaymentSchedule.getTotalWaived().doubleValue(), + Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + case "Outstanding" -> assertThat(repaymentSchedule.getTotalOutstanding().doubleValue())// + .as(ErrorMessageHelper.wrongAmountInRepaymentScheduleOutstanding( + repaymentSchedule.getTotalOutstanding().doubleValue(), Double.valueOf(expectedValue)))// + .isEqualTo(Double.valueOf(expectedValue));// + } + } + return actualValues; + } + } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index ca1f1e89d..30db0f017 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -48,6 +48,7 @@ public abstract class TestContextKey { public static final String LOAN_REFUND_RESPONSE = "loanRefundResponse"; public static final String LOAN_REAGING_RESPONSE = "loanReAgingResponse"; public static final String LOAN_REAGING_UNDO_RESPONSE = "loanReAgingUndoResponse"; + public static final String LOAN_REAGING_PREVIEW_RESPONSE = "loanReAgingPreviewResponse"; public static final String LOAN_REAMORTIZATION_RESPONSE = "loanReAmortizationResponse"; public static final String LOAN_REAMORTIZATION_UNDO_RESPONSE = "loanReAmortizationUndoResponse"; public static final String BUSINESS_DATE_RESPONSE = "businessDateResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanReAgingPreview.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAgingPreview.feature new file mode 100644 index 000000000..3cfb4214f --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAgingPreview.feature @@ -0,0 +1,454 @@ +@LoanReAgingPreviewFeature +Feature: LoanReAgingPreview + + Scenario: Basic verification of the loan re-aging preview schedule + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | + When Admin sets the business date to "15 April 2025" + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 15 April 2025 | 3 | + Then Loan Repayment schedule preview has 7 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 15 April 2025 | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 2 | 31 | 01 February 2025 | 15 April 2025 | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 28 | 01 March 2025 | 15 April 2025 | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 31 | 01 April 2025 | 15 April 2025 | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 5 | 14 | 15 April 2025 | | 666.67 | 333.33 | 0.0 | 0.0 | 0.0 | 333.33 | 0.0 | 0.0 | 0.0 | 333.33 | + | 6 | 30 | 15 May 2025 | | 333.34 | 333.33 | 0.0 | 0.0 | 0.0 | 333.33 | 0.0 | 0.0 | 0.0 | 333.33 | + | 7 | 31 | 15 June 2025 | | 0.0 | 333.34 | 0.0 | 0.0 | 0.0 | 333.34 | 0.0 | 0.0 | 0.0 | 333.34 | + Then Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | + + Scenario: Verify Loan re-aging preview with chargeback + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2024 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 45 | DAYS | 15 | DAYS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2024" with "1000" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "1000" EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + When Admin sets the business date to "01 January 2024" + And Customer makes "AUTOPAY" repayment on "01 January 2024" with 250 EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + When Admin sets the business date to "02 February 2024" + When Admin makes "REPAYMENT_ADJUSTMENT_CHARGEBACK" chargeback with 125 EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 375.0 | 0.0 | 0.0 | 0.0 | 375.0 | 0.0 | 0.0 | 0.0 | 375.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1125.0 | 0.0 | 0.0 | 0.0 | 1125.0 | 250.0 | 0.0 | 0.0 | 875.0 | + When Admin sets the business date to "21 February 2024" + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 2 | MONTHS | 10 March 2024 | 3 | + Then Loan Repayment schedule preview has 7 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 21 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 21 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 21 February 2024 | 875.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 5 | 24 | 10 March 2024 | | 583.33 | 291.67 | 0.0 | 0.0 | 0.0 | 291.67 | 0.0 | 0.0 | 0.0 | 291.67 | + | 6 | 61 | 10 May 2024 | | 291.66 | 291.67 | 0.0 | 0.0 | 0.0 | 291.67 | 0.0 | 0.0 | 0.0 | 291.67 | + | 7 | 61 | 10 July 2024 | | 0.0 | 291.66 | 0.0 | 0.0 | 0.0 | 291.66 | 0.0 | 0.0 | 0.0 | 291.66 | + And Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 375.0 | 0.0 | 0.0 | 0.0 | 375.0 | 0.0 | 0.0 | 0.0 | 375.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1125.0 | 0.0 | 0.0 | 0.0 | 1125.0 | 250.0 | 0.0 | 0.0 | 875.0 | + + Scenario: Verify Loan re-aging preview with charge N+1 installment after maturity date + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + When Admin sets the business date to "3 May 2025" + And Admin adds "LOAN_NSF_FEE" due date charge with "3 May 2025" due date and 10 EUR transaction amount + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 0.0 | 0.0 | 0.0 | 1010.0 | + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | WEEKS | 01 April 2025 | 1 | + Then Loan Repayment schedule preview has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 April 2025 | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 2 | 31 | 01 February 2025 | 01 April 2025 | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 28 | 01 March 2025 | 01 April 2025 | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + And Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 0.0 | 0.0 | 0.0 | 1010.0 | + And Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 0.0 | 0.0 | 0.0 | 1010.0 | + + Scenario: Verify Loan re-aging preview with backdated repayment, charge and N+1 installment after maturity date + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + When Admin sets the business date to "3 May 2025" + And Customer makes "AUTOPAY" repayment on "01 March 2025" with 250 EUR transaction amount + And Admin adds "LOAN_NSF_FEE" due date charge with "3 May 2025" due date and 10 EUR transaction amount + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 250.0 | 0.0 | 250.0 | 760.0 | + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | WEEKS | 01 April 2025 | 1 | + Then Loan Repayment schedule preview has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | 01 April 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 28 | 01 March 2025 | 01 April 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 750.0 | 0.0 | 0.0 | 0.0 | 750.0 | 0.0 | 0.0 | 0.0 | 750.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + And Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 250.0 | 0.0 | 250.0 | 760.0 | + And Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 250.0 | 0.0 | 250.0 | 760.0 | + + Scenario: Verify Loan re-aging preview with downpayment, payoff and charge - N+1 installment after maturity date + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 45 | DAYS | 15 | DAYS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + When Admin sets the business date to "20 March 2025" + When Loan Pay-off is made on "20 March 2025" + And Admin adds "LOAN_NSF_FEE" due date charge with "20 March 2025" due date and 10 EUR transaction amount + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 January 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2025 | 20 March 2025 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2025 | 20 March 2025 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2025 | 20 March 2025 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 5 | 33 | 20 March 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 1000.0 | 0.0 | 750.0 | 10.0 | + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | WEEKS | 15 February 2025 | 1 | + Then Loan Repayment schedule preview has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 January 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2025 | 15 February 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 15 | 31 January 2025 | 15 February 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 15 | 15 February 2025 | 20 March 2025 | 0.0 | 750.0 | 0.0 | 0.0 | 0.0 | 750.0 | 750.0 | 0.0 | 750.0 | 0.0 | + | 5 | 33 | 20 March 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + And Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 1000.0 | 0.0 | 750.0 | 10.0 | + And Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 January 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2025 | 20 March 2025 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2025 | 20 March 2025 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2025 | 20 March 2025 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 5 | 33 | 20 March 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 1000.0 | 0.0 | 750.0 | 10.0 | + + Scenario: Verify that Loan re-aging preview with repayment, chargeback and charge - N+1 installment after maturity date + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + When Admin sets the business date to "3 May 2025" + And Customer makes "AUTOPAY" repayment on "01 March 2025" with 250 EUR transaction amount + When Admin makes "REPAYMENT_ADJUSTMENT_CHARGEBACK" chargeback with 125 EUR transaction amount + And Admin adds "LOAN_NSF_FEE" due date charge with "3 May 2025" due date and 10 EUR transaction amount + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 125.0 | 0.0 | 0.0 | 10.0 | 135.0 | 0.0 | 0.0 | 0.0 | 135.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1125.0 | 0.0 | 0.0 | 10.0 | 1135.0 | 250.0 | 0.0 | 250.0 | 885.0 | + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | WEEKS | 01 April 2025 | 1 | + Then Loan Repayment schedule preview has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | 01 April 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 28 | 01 March 2025 | 01 April 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 750.0 | 0.0 | 0.0 | 0.0 | 750.0 | 0.0 | 0.0 | 0.0 | 750.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 125.0 | 0.0 | 0.0 | 10.0 | 135.0 | 0.0 | 0.0 | 0.0 | 135.0 | + And Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1125.0 | 0.0 | 0.0 | 10.0 | 1135.0 | 250.0 | 0.0 | 250.0 | 885.0 | + And Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 125.0 | 0.0 | 0.0 | 10.0 | 135.0 | 0.0 | 0.0 | 0.0 | 135.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1125.0 | 0.0 | 0.0 | 10.0 | 1135.0 | 250.0 | 0.0 | 250.0 | 885.0 | + + Scenario: Verify that Loan re-aging preview with repayment, charge and charge adjustment - N+1 installment after maturity date + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + When Admin sets the business date to "3 May 2025" + And Customer makes "AUTOPAY" repayment on "01 March 2025" with 250 EUR transaction amount + And Admin adds "LOAN_NSF_FEE" due date charge with "03 May 2025" due date and 20 EUR transaction amount + When Admin sets the business date to "04 May 2025" + When Admin makes a charge adjustment for the last "LOAN_NSF_FEE" type charge which is due on "03 May 2025" with 20 EUR transaction amount and externalId "" + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 20.0 | 0.0 | 20.0 | 230.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 20.0 | 1020.0 | 270.0 | 0.0 | 270.0 | 750.0 | + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | WEEKS | 01 April 2025 | 1 | + Then Loan Repayment schedule preview has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | 01 April 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 28 | 01 March 2025 | 01 April 2025 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 750.0 | 0.0 | 0.0 | 0.0 | 750.0 | 20.0 | 0.0 | 20.0 | 730.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + Then Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 20.0 | 1020.0 | 270.0 | 0.0 | 270.0 | 750.0 | + And Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 March 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 20.0 | 0.0 | 20.0 | 230.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 20.0 | 1020.0 | 270.0 | 0.0 | 270.0 | 750.0 | + + Scenario: Verify that Loan re-aging transaction with MIR and charge - N+1 installment after maturity date + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + And Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + And Admin sets the business date to "03 May 2025" + And Admin makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "01 April 2025" with 100 EUR transaction amount + And Admin adds "LOAN_NSF_FEE" due date charge with "03 May 2025" due date and 20 EUR transaction amount + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 100.0 | 0.0 | 100.0 | 150.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 20.0 | 1020.0 | 100.0 | 0.0 | 100.0 | 920.0 | + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | WEEKS | 01 April 2025 | 1 | + Then Loan Repayment schedule preview has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 April 2025 | 900.0 | 100.0 | 0.0 | 0.0 | 0.0 | 100.0 | 100.0 | 0.0 | 100.0 | 0.0 | + | 2 | 31 | 01 February 2025 | 01 April 2025 | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 28 | 01 March 2025 | 01 April 2025 | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 900.0 | 0.0 | 0.0 | 0.0 | 900.0 | 0.0 | 0.0 | 0.0 | 900.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + And Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 20.0 | 1020.0 | 100.0 | 0.0 | 100.0 | 920.0 | + And Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 100.0 | 0.0 | 100.0 | 150.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 5 | 32 | 03 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 20.0 | 1020.0 | 100.0 | 0.0 | 100.0 | 920.0 | + + Scenario: Verify that Loan re-aging preview with 2nd disbursement and charge - N+1 installment after maturity date + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "500" amount and expected disbursement date on "01 January 2025" + And Admin successfully disburse the loan on "01 January 2025" with "500" EUR transaction amount + And Admin sets the business date to "01 May 2025" + And Admin successfully disburse the loan on "16 January 2025" with "100" EUR transaction amount + And Admin adds "LOAN_NSF_FEE" due date charge with "01 May 2025" due date and 20 EUR transaction amount + Then Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 375.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | + | | | 16 January 2025 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 0 | 16 January 2025 | | 450.0 | 25.0 | 0.0 | 0.0 | 0.0 | 25.0 | 0.0 | 0.0 | 0.0 | 25.0 | + | 3 | 31 | 01 February 2025 | | 300.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | + | 4 | 28 | 01 March 2025 | | 150.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | + | 5 | 31 | 01 April 2025 | | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | + | 6 | 30 | 01 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 600.0 | 0.0 | 0.0 | 20.0 | 620.0 | 0.0 | 0.0 | 0.0 | 620.0 | + When Admin creates a Loan re-aging preview by Loan external ID with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | WEEKS | 01 April 2025 | 1 | + Then Loan Repayment schedule preview has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 April 2025 | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | | | 16 January 2025 | | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | | | | + | 2 | 0 | 16 January 2025 | 01 April 2025 | 600.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 31 | 01 February 2025 | 01 April 2025 | 600.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 28 | 01 March 2025 | 01 April 2025 | 600.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 5 | 31 | 01 April 2025 | | 0.0 | 600.0 | 0.0 | 0.0 | 0.0 | 600.0 | 0.0 | 0.0 | 0.0 | 600.0 | + | 6 | 30 | 01 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + Then Loan Repayment schedule preview has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 600.0 | 0.0 | 0.0 | 20.0 | 620.0 | 0.0 | 0.0 | 0.0 | 620.0 | + And Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | | 375.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | + | | | 16 January 2025 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 0 | 16 January 2025 | | 450.0 | 25.0 | 0.0 | 0.0 | 0.0 | 25.0 | 0.0 | 0.0 | 0.0 | 25.0 | + | 3 | 31 | 01 February 2025 | | 300.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | + | 4 | 28 | 01 March 2025 | | 150.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | + | 5 | 31 | 01 April 2025 | | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | 0.0 | 0.0 | 0.0 | 150.0 | + | 6 | 30 | 01 May 2025 | | 0.0 | 0.0 | 0.0 | 0.0 | 20.0 | 20.0 | 0.0 | 0.0 | 0.0 | 20.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 600.0 | 0.0 | 0.0 | 20.0 | 620.0 | 0.0 | 0.0 | 0.0 | 620.0 | \ No newline at end of file diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallmentRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallmentRepository.java index 8cbf1748b..c7c7af96e 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallmentRepository.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallmentRepository.java @@ -64,4 +64,12 @@ public interface LoanRepaymentScheduleInstallmentRepository @Param("businessDate") LocalDate businessDate, @Param("obligationsMet") boolean obligationsMet, @Param("loanIds") List loanIds); + @Query(""" + SELECT i + FROM LoanRepaymentScheduleInstallment i + WHERE i.loan.id = :loanId + ORDER BY i.loan.id, i.installmentNumber + """) + List findByLoanId(@Param("loanId") Long loanId); + } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsService.java index 72bc66f15..2fd1c9b84 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsService.java @@ -32,6 +32,8 @@ public interface ReprocessLoanTransactionsService { void reprocessTransactionsWithPostTransactionChecks(Loan loan, LocalDate transactionDate); + void reprocessTransactionsWithoutChecks(Loan loan, LocalDate transactionDate, List newTransactions); + void processPostDisbursementTransactions(Loan loan); void removeLoanCharge(Loan loan, LoanCharge loanCharge); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 79d680d81..bb12a9191 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -2868,7 +2868,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep target.setReAged(true); target.setFromDate(reAgedInstallment.getFromDate()); target.setDueDate(reAgedInstallment.getDueDate()); - target.setPrincipal(reAgedInstallment.getPrincipal().add(MathUtil.nullToZero(target.getPrincipalCompleted()))); + target.setPrincipal(MathUtil.add(reAgedInstallment.getPrincipal(), target.getPrincipalCompleted())); target.setInterestCharged(MathUtil.add(reAgedInstallment.getInterestCharged(), target.getInterestPaid())); target.updateObligationsMet(currency, transactionDate); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java index a3627c9ac..b0ab25e15 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java @@ -27,6 +27,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; @@ -63,14 +65,17 @@ import org.apache.fineract.infrastructure.core.service.CommandParameterUtil; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest; import org.apache.fineract.portfolio.loanaccount.data.LoanRepaymentScheduleInstallmentData; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException; import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData; import org.apache.fineract.portfolio.loanaccount.service.LoanChargePaidByReadService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; +import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingService; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadPlatformService; import org.springframework.data.domain.Page; @@ -104,6 +109,7 @@ public class LoanTransactionsApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PaymentTypeReadPlatformService paymentTypeReadPlatformService; private final LoanChargePaidByReadService loanChargePaidByReadService; + private final LoanReAgingService loanReAgingService; @GET @Path("{loanId}/transactions/template") @@ -777,4 +783,26 @@ public class LoanTransactionsApiResource { throw new IllegalArgumentException("loanId and loanExternalId cannot be both null"); } } + + @GET + @Path("{loanId}/transactions/reage-preview") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Preview Re-Age Schedule", description = "Generates a preview of the re-aged loan schedule based on the provided parameters without creating any transactions or modifying the loan.") + public LoanScheduleData previewReAgeSchedule(@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @Valid @BeanParam final ReAgePreviewRequest reAgePreviewRequest) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return loanReAgingService.previewReAge(loanId, null, reAgePreviewRequest); + } + + @GET + @Path("external-id/{loanExternalId}/transactions/reage-preview") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Preview Re-Age Schedule", description = "Generates a preview of the re-aged loan schedule based on the provided parameters without creating any transactions or modifying the loan.") + public LoanScheduleData previewReAgeSchedule( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @Valid @BeanParam final ReAgePreviewRequest reAgePreviewRequest) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return loanReAgingService.previewReAge(null, loanExternalId, reAgePreviewRequest); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/request/ReAgePreviewRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/request/ReAgePreviewRequest.java new file mode 100644 index 000000000..9dfcbe6cc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/request/ReAgePreviewRequest.java @@ -0,0 +1,81 @@ +/** + * 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.portfolio.loanaccount.api.request; + +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.QueryParam; +import java.io.Serial; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.validation.constraints.EnumValue; +import org.apache.fineract.validation.constraints.LocalDate; +import org.apache.fineract.validation.constraints.Locale; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@LocalDate(dateField = "startDate", formatField = "dateFormat", localeField = "locale") +public class ReAgePreviewRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @QueryParam("frequencyNumber") + @Parameter(description = "The frequency number for the re-aging schedule", required = true) + @NotNull(message = "{org.apache.fineract.reage.frequency-number.not-blank}") + @Min(value = 1, message = "{org.apache.fineract.reage.frequency-number.min}") + private Integer frequencyNumber; + + @QueryParam("frequencyType") + @Parameter(description = "The frequency type (DAYS, WEEKS, MONTHS, YEARS)", required = true) + @NotBlank(message = "{org.apache.fineract.reage.frequency-type.not-blank}") + @EnumValue(enumClass = PeriodFrequencyType.class, message = "{org.apache.fineract.frequency-type.invalid}") + private String frequencyType; + + @QueryParam("startDate") + @Parameter(description = "The start date for the re-aging schedule", required = true) + @NotBlank(message = "{org.apache.fineract.reage.start-date.not-blank}") + private String startDate; + + @QueryParam("numberOfInstallments") + @Parameter(description = "The number of installments for the re-aged loan", required = true) + @NotNull(message = "{org.apache.fineract.reage.number-of-installments.not-blank}") + @Min(value = 1, message = "{org.apache.fineract.reage.number-of-installments.min}") + private Integer numberOfInstallments; + + @QueryParam("dateFormat") + @Parameter(description = "The date format used for the startDate parameter", required = true) + @NotBlank(message = "{org.apache.fineract.businessdate.date-format.not-blank}") + private String dateFormat; + + @QueryParam("locale") + @Parameter(description = "The locale to use for formatting", required = true) + @NotBlank(message = "{org.apache.fineract.businessdate.locale.not-blank}") + @Locale + private String locale; + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanReAgingCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanReAgingCommandHandler.java index 7f3778246..bb5398cd1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanReAgingCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanReAgingCommandHandler.java @@ -24,7 +24,7 @@ import org.apache.fineract.commands.handler.NewCommandSourceHandler; import org.apache.fineract.infrastructure.DataIntegrityErrorHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingServiceImpl; +import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingService; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.stereotype.Service; @@ -34,7 +34,7 @@ import org.springframework.stereotype.Service; @CommandType(entity = "LOAN", action = "REAGE") public class LoanReAgingCommandHandler implements NewCommandSourceHandler { - private final LoanReAgingServiceImpl loanReAgingService; + private final LoanReAgingService loanReAgingService; private final DataIntegrityErrorHandler dataIntegrityErrorHandler; @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanUndoReAgingCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanUndoReAgingCommandHandler.java index d66583d2c..4187c5466 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanUndoReAgingCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/loan/reaging/LoanUndoReAgingCommandHandler.java @@ -24,7 +24,7 @@ import org.apache.fineract.commands.handler.NewCommandSourceHandler; import org.apache.fineract.infrastructure.DataIntegrityErrorHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingServiceImpl; +import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingService; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.stereotype.Service; @@ -34,7 +34,7 @@ import org.springframework.stereotype.Service; @CommandType(entity = "LOAN", action = "UNDO_REAGE") public class LoanUndoReAgingCommandHandler implements NewCommandSourceHandler { - private final LoanReAgingServiceImpl loanReAgingService; + private final LoanReAgingService loanReAgingService; private final DataIntegrityErrorHandler dataIntegrityErrorHandler; @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java index bc18d4bc5..a594f8705 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java @@ -31,8 +31,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -103,7 +101,6 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanApprovalData; import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByData; import org.apache.fineract.portfolio.loanaccount.data.LoanInterestRecalculationData; import org.apache.fineract.portfolio.loanaccount.data.LoanRepaymentScheduleInstallmentData; -import org.apache.fineract.portfolio.loanaccount.data.LoanSchedulePeriodDataWrapper; import org.apache.fineract.portfolio.loanaccount.data.LoanStatusEnumData; import org.apache.fineract.portfolio.loanaccount.data.LoanSummaryData; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; @@ -136,7 +133,6 @@ import org.apache.fineract.portfolio.loanaccount.domain.reamortization.LoanReAmo import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException; import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData; -import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePeriodData; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; @@ -158,9 +154,7 @@ import org.springframework.dao.DataAccessException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.data.domain.Pageable; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; -import org.springframework.lang.NonNull; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; @@ -188,7 +182,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa private final ConfigurationDomainService configurationDomainService; private final AccountDetailsReadPlatformService accountDetailsReadPlatformService; private final ColumnValidator columnValidator; - protected final DatabaseSpecificSQLGenerator sqlGenerator; + private final DatabaseSpecificSQLGenerator sqlGenerator; private final DelinquencyReadPlatformService delinquencyReadPlatformService; private final LoanTransactionRepository loanTransactionRepository; private final LoanChargePaidByReadService loanChargePaidByReadService; @@ -201,6 +195,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa private final LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository; private final InterestRefundServiceDelegate interestRefundServiceDelegate; private final LoanMaximumAmountCalculator loanMaximumAmountCalculator; + private final LoanRepaymentScheduleService loanRepaymentScheduleService; @Override public LoanAccountData retrieveOne(final Long loanId) { @@ -280,19 +275,9 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection disbursementData, Collection capitalizedIncomeData, boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) { - - try { - this.context.authenticatedUser(); - - final LoanScheduleResultSetExtractor fullResultsetExtractor = new LoanScheduleResultSetExtractor( - repaymentScheduleRelatedLoanData, disbursementData, capitalizedIncomeData, isInterestRecalculationEnabled, - loanScheduleType); - final String sql = "select " + fullResultsetExtractor.schema() + " where ls.loan_id = ? order by ls.loan_id, ls.installment"; - - return this.jdbcTemplate.query(sql, fullResultsetExtractor, loanId); // NOSONAR - } catch (final EmptyResultDataAccessException e) { - throw new LoanNotFoundException(loanId, e); - } + this.context.authenticatedUser(); + return loanRepaymentScheduleService.findLoanScheduleData(loanId, repaymentScheduleRelatedLoanData, disbursementData, + capitalizedIncomeData, isInterestRecalculationEnabled, loanScheduleType); } @Override @@ -1339,373 +1324,6 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa } } - private static final class LoanScheduleResultSetExtractor implements ResultSetExtractor { - - private final CurrencyData currency; - private final DisbursementData disbursement; - private final BigDecimal totalFeeChargesDueAtDisbursement; - private final Collection disbursementData; - private final Collection capitalizedIncomeData; - private final LoanScheduleType loanScheduleType; - private LocalDate lastDueDate; - private BigDecimal outstandingLoanPrincipalBalance; - private boolean excludePastUnDisbursed; - - LoanScheduleResultSetExtractor(final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, - Collection disbursementData, Collection capitalizedIncomeData, - boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) { - this.currency = repaymentScheduleRelatedLoanData.getCurrency(); - this.disbursement = repaymentScheduleRelatedLoanData.disbursementData(); - this.capitalizedIncomeData = capitalizedIncomeData; - this.totalFeeChargesDueAtDisbursement = repaymentScheduleRelatedLoanData.getTotalFeeChargesAtDisbursement(); - this.lastDueDate = this.disbursement.disbursementDate(); - this.outstandingLoanPrincipalBalance = this.disbursement.getPrincipal(); - this.disbursementData = disbursementData; - this.excludePastUnDisbursed = isInterestRecalculationEnabled; - this.loanScheduleType = loanScheduleType; - } - - public String schema() { - - return " ls.loan_id as loanId, ls.installment as period, ls.fromdate as fromDate, ls.duedate as dueDate, ls.obligations_met_on_date as obligationsMetOnDate, ls.completed_derived as complete," - + " ls.principal_amount as principalDue, ls.principal_completed_derived as principalPaid, ls.principal_writtenoff_derived as principalWrittenOff, ls.is_additional as isAdditional, " - + " ls.interest_amount as interestDue, ls.interest_completed_derived as interestPaid, ls.interest_waived_derived as interestWaived, ls.interest_writtenoff_derived as interestWrittenOff, " - + " ls.fee_charges_amount as feeChargesDue, ls.fee_charges_completed_derived as feeChargesPaid, ls.fee_charges_waived_derived as feeChargesWaived, ls.fee_charges_writtenoff_derived as feeChargesWrittenOff, " - + " ls.penalty_charges_amount as penaltyChargesDue, ls.penalty_charges_completed_derived as penaltyChargesPaid, ls.penalty_charges_waived_derived as penaltyChargesWaived, " - + " ls.penalty_charges_writtenoff_derived as penaltyChargesWrittenOff, ls.total_paid_in_advance_derived as totalPaidInAdvanceForPeriod, " - + " ls.total_paid_late_derived as totalPaidLateForPeriod, ls.credits_amount as principalCredits, ls.credited_fee as feeCredits, ls.credited_penalty as penaltyCredits, ls.is_down_payment isDownPayment, " - + " ls.accrual_interest_derived as accrualInterest " + " from m_loan_repayment_schedule ls "; - } - - @Override - public LoanScheduleData extractData(@NonNull final ResultSet rs) throws SQLException, DataAccessException { - BigDecimal waivedChargeAmount = BigDecimal.ZERO; - for (DisbursementData disbursementDetail : disbursementData) { - waivedChargeAmount = waivedChargeAmount.add(disbursementDetail.getWaivedChargeAmount()); - } - final LoanSchedulePeriodData disbursementPeriod = LoanSchedulePeriodData.disbursementOnlyPeriod( - this.disbursement.disbursementDate(), this.disbursement.getPrincipal(), this.totalFeeChargesDueAtDisbursement, - this.disbursement.isDisbursed()); - - final List periods = new ArrayList<>(); - final MonetaryCurrency monCurrency = new MonetaryCurrency(this.currency.getCode(), this.currency.getDecimalPlaces(), - this.currency.getInMultiplesOf()); - BigDecimal totalPrincipalDisbursed = BigDecimal.ZERO; - BigDecimal disbursementChargeAmount = this.totalFeeChargesDueAtDisbursement; - if (disbursementData.isEmpty()) { - periods.add(disbursementPeriod); - totalPrincipalDisbursed = Money.of(monCurrency, this.disbursement.getPrincipal()).getAmount(); - } else { - if (!this.disbursement.isDisbursed()) { - excludePastUnDisbursed = false; - } - for (DisbursementData data : disbursementData) { - if (data.getChargeAmount() != null) { - disbursementChargeAmount = disbursementChargeAmount.subtract(data.getChargeAmount()); - } - } - this.outstandingLoanPrincipalBalance = BigDecimal.ZERO; - } - - Money totalPrincipalExpected = Money.zero(monCurrency); - Money totalPrincipalPaid = Money.zero(monCurrency); - Money totalInterestCharged = Money.zero(monCurrency); - Money totalFeeChargesCharged = Money.zero(monCurrency); - Money totalPenaltyChargesCharged = Money.zero(monCurrency); - Money totalWaived = Money.zero(monCurrency); - Money totalWrittenOff = Money.zero(monCurrency); - Money totalRepaymentExpected = Money.zero(monCurrency); - Money totalRepayment = Money.zero(monCurrency); - Money totalPaidInAdvance = Money.zero(monCurrency); - Money totalPaidLate = Money.zero(monCurrency); - Money totalOutstanding = Money.zero(monCurrency); - Money totalCredits = Money.zero(monCurrency); - - // update totals with details of fees charged during disbursement - totalFeeChargesCharged = totalFeeChargesCharged.plus(disbursementPeriod.getFeeChargesDue().subtract(waivedChargeAmount)); - totalRepaymentExpected = totalRepaymentExpected.plus(disbursementPeriod.getFeeChargesDue()).minus(waivedChargeAmount); - totalRepayment = totalRepayment.plus(disbursementPeriod.getFeeChargesPaid()).minus(waivedChargeAmount); - totalOutstanding = totalOutstanding.plus(disbursementPeriod.getFeeChargesDue()).minus(disbursementPeriod.getFeeChargesPaid()); - - Integer loanTermInDays = 0; - Set disbursementPeriodIds = new HashSet<>(); - while (rs.next()) { - final Integer period = JdbcSupport.getInteger(rs, "period"); - LocalDate fromDate = JdbcSupport.getLocalDate(rs, "fromDate"); - final LocalDate dueDate = JdbcSupport.getLocalDate(rs, "dueDate"); - final LocalDate obligationsMetOnDate = JdbcSupport.getLocalDate(rs, "obligationsMetOnDate"); - final boolean complete = rs.getBoolean("complete"); - - List combinedDataList = new ArrayList<>(); - combinedDataList.addAll( - collectEligibleDisbursementData(loanScheduleType, disbursementData, fromDate, dueDate, disbursementPeriodIds)); - combinedDataList.addAll(collectEligibleCapitalizedIncomeData(fromDate, dueDate, disbursementPeriodIds)); - combinedDataList.sort(this::sortPeriodDataHolders); - fillLoanSchedulePeriodData(periods, combinedDataList, disbursementChargeAmount, waivedChargeAmount); - - BigDecimal disbursedAmount = calculateDisbursedAmount(combinedDataList); - - // Add the Charge back or Credits to the initial amount to avoid negative balance - final BigDecimal principalCredits = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalCredits"); - final BigDecimal feeCredits = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeCredits"); - final BigDecimal penaltyCredits = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyCredits"); - final BigDecimal credits = principalCredits.add(feeCredits).add(penaltyCredits); - this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.add(principalCredits); - - totalPrincipalDisbursed = totalPrincipalDisbursed.add(disbursedAmount); - - Integer daysInPeriod = 0; - if (fromDate != null) { - daysInPeriod = DateUtils.getExactDifferenceInDays(fromDate, dueDate); - loanTermInDays = loanTermInDays + daysInPeriod; - } - - final BigDecimal principalDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalDue"); - totalPrincipalExpected = totalPrincipalExpected.plus(principalDue); - final BigDecimal principalPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalPaid"); - totalPrincipalPaid = totalPrincipalPaid.plus(principalPaid); - final BigDecimal principalWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalWrittenOff"); - - final BigDecimal principalOutstanding = principalDue.subtract(principalPaid).subtract(principalWrittenOff); - - final BigDecimal interestExpectedDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestDue"); - totalInterestCharged = totalInterestCharged.plus(interestExpectedDue); - final BigDecimal interestPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestPaid"); - final BigDecimal interestWaived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestWaived"); - final BigDecimal interestWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestWrittenOff"); - final BigDecimal accrualInterest = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accrualInterest"); - - final BigDecimal interestActualDue = interestExpectedDue.subtract(interestWaived).subtract(interestWrittenOff); - final BigDecimal interestOutstanding = interestActualDue.subtract(interestPaid); - - final BigDecimal feeChargesExpectedDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesDue"); - totalFeeChargesCharged = totalFeeChargesCharged.plus(feeChargesExpectedDue); - final BigDecimal feeChargesPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesPaid"); - final BigDecimal feeChargesWaived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesWaived"); - final BigDecimal feeChargesWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesWrittenOff"); - - final BigDecimal feeChargesActualDue = feeChargesExpectedDue.subtract(feeChargesWaived).subtract(feeChargesWrittenOff); - final BigDecimal feeChargesOutstanding = feeChargesActualDue.subtract(feeChargesPaid); - - final BigDecimal penaltyChargesExpectedDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesDue"); - totalPenaltyChargesCharged = totalPenaltyChargesCharged.plus(penaltyChargesExpectedDue); - final BigDecimal penaltyChargesPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesPaid"); - final BigDecimal penaltyChargesWaived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesWaived"); - final BigDecimal penaltyChargesWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesWrittenOff"); - - final BigDecimal totalPaidInAdvanceForPeriod = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, - "totalPaidInAdvanceForPeriod"); - final BigDecimal totalPaidLateForPeriod = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalPaidLateForPeriod"); - - final BigDecimal penaltyChargesActualDue = penaltyChargesExpectedDue.subtract(penaltyChargesWaived) - .subtract(penaltyChargesWrittenOff); - final BigDecimal penaltyChargesOutstanding = penaltyChargesActualDue.subtract(penaltyChargesPaid); - - final BigDecimal totalExpectedCostOfLoanForPeriod = interestExpectedDue.add(feeChargesExpectedDue) - .add(penaltyChargesExpectedDue); - - final BigDecimal totalDueForPeriod = principalDue.add(totalExpectedCostOfLoanForPeriod); - final BigDecimal totalPaidForPeriod = principalPaid.add(interestPaid).add(feeChargesPaid).add(penaltyChargesPaid); - final BigDecimal totalWaivedForPeriod = interestWaived.add(feeChargesWaived).add(penaltyChargesWaived); - totalWaived = totalWaived.plus(totalWaivedForPeriod); - final BigDecimal totalWrittenOffForPeriod = principalWrittenOff.add(interestWrittenOff).add(feeChargesWrittenOff) - .add(penaltyChargesWrittenOff); - totalWrittenOff = totalWrittenOff.plus(totalWrittenOffForPeriod); - - final BigDecimal totalOutstandingForPeriod = principalOutstanding.add(interestOutstanding).add(feeChargesOutstanding) - .add(penaltyChargesOutstanding); - - totalRepaymentExpected = totalRepaymentExpected.plus(totalDueForPeriod); - totalRepayment = totalRepayment.plus(totalPaidForPeriod); - totalPaidInAdvance = totalPaidInAdvance.plus(totalPaidInAdvanceForPeriod); - totalPaidLate = totalPaidLate.plus(totalPaidLateForPeriod); - totalOutstanding = totalOutstanding.plus(totalOutstandingForPeriod); - totalCredits = totalCredits.add(credits); - - if (fromDate == null) { - fromDate = this.lastDueDate; - } - - BigDecimal outstandingPrincipalBalanceOfLoan = this.outstandingLoanPrincipalBalance.subtract(principalDue); - - // update based on current period values - this.lastDueDate = dueDate; - this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.subtract(principalDue); - - final boolean isDownPayment = rs.getBoolean("isDownPayment"); - - LoanSchedulePeriodData periodData; - - periodData = LoanSchedulePeriodData.periodWithPayments(period, fromDate, dueDate, obligationsMetOnDate, complete, - principalDue, principalPaid, principalWrittenOff, principalOutstanding, outstandingPrincipalBalanceOfLoan, - interestExpectedDue, interestPaid, interestWaived, interestWrittenOff, interestOutstanding, feeChargesExpectedDue, - feeChargesPaid, feeChargesWaived, feeChargesWrittenOff, feeChargesOutstanding, penaltyChargesExpectedDue, - penaltyChargesPaid, penaltyChargesWaived, penaltyChargesWrittenOff, penaltyChargesOutstanding, totalPaidForPeriod, - totalPaidInAdvanceForPeriod, totalPaidLateForPeriod, totalWaivedForPeriod, totalWrittenOffForPeriod, credits, - isDownPayment, accrualInterest); - - periods.add(periodData); - } - - return new LoanScheduleData(this.currency, periods, loanTermInDays, totalPrincipalDisbursed, totalPrincipalExpected.getAmount(), - totalPrincipalPaid.getAmount(), totalInterestCharged.getAmount(), totalFeeChargesCharged.getAmount(), - totalPenaltyChargesCharged.getAmount(), totalWaived.getAmount(), totalWrittenOff.getAmount(), - totalRepaymentExpected.getAmount(), totalRepayment.getAmount(), totalPaidInAdvance.getAmount(), - totalPaidLate.getAmount(), totalOutstanding.getAmount(), totalCredits.getAmount()); - } - - private List collectEligibleDisbursementData(LoanScheduleType loanScheduleType, - Collection disbursementData, LocalDate fromDate, LocalDate dueDate, Set disbursementPeriodIds) { - List disbursementDataList = new ArrayList<>(); - - boolean hasMultipleTranchesOnSameDate = hasMultipleTranchesOnSameDate(disbursementData); - - if (hasMultipleTranchesOnSameDate) { - Map> disbursementsByDate = new HashMap<>(); - - for (final DisbursementData data : disbursementData) { - boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate); - boolean isEligible = ((fromDate.equals(this.disbursement.disbursementDate()) - && data.disbursementDate().equals(fromDate)) - || (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate)) - || canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed)) - && !disbursementPeriodIds.contains(data.getId()); - - if (isEligible) { - disbursementsByDate.computeIfAbsent(data.disbursementDate(), k -> new ArrayList<>()).add(data); - disbursementPeriodIds.add(data.getId()); - } - } - - for (Map.Entry> entry : disbursementsByDate.entrySet()) { - List sameDateDisbursements = entry.getValue(); - - if (sameDateDisbursements.size() > 1) { - List disbursedTranches = sameDateDisbursements.stream().filter(DisbursementData::isDisbursed) - .collect(Collectors.toList()); - - if (!disbursedTranches.isEmpty()) { - for (DisbursementData data : disbursedTranches) { - disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); - } - } else { - for (DisbursementData data : sameDateDisbursements) { - disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); - } - } - } else { - DisbursementData data = sameDateDisbursements.get(0); - disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); - } - } - } else { - for (final DisbursementData data : disbursementData) { - boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate); - boolean isEligible = ((fromDate.equals(this.disbursement.disbursementDate()) - && data.disbursementDate().equals(fromDate)) - || (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate)) - || canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed)) - && !disbursementPeriodIds.contains(data.getId()); - - if (isEligible) { - disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); - disbursementPeriodIds.add(data.getId()); - } - } - } - - return disbursementDataList; - } - - private boolean hasMultipleTranchesOnSameDate(Collection disbursementData) { - if (disbursementData == null || disbursementData.size() <= 1) { - return false; - } - return disbursementData.stream().collect(Collectors.groupingBy(DisbursementData::disbursementDate, Collectors.counting())) - .values().stream().anyMatch(count -> count > 1); - } - - private List collectEligibleCapitalizedIncomeData(LocalDate fromDate, LocalDate dueDate, - Set disbursementPeriodIds) { - List capitalizedIncomeDataList = new ArrayList<>(); - // Collect eligible capitalized income data - for (LoanTransactionRepaymentPeriodData data : capitalizedIncomeData) { - boolean isEligible = canAddCapitalizedIncomeData(data, fromDate, dueDate) - && !disbursementPeriodIds.contains(data.getTransactionId()); - - if (isEligible) { - capitalizedIncomeDataList.add(new LoanSchedulePeriodDataWrapper(data, data.getDate(), false)); - disbursementPeriodIds.add(data.getTransactionId()); - } - } - return capitalizedIncomeDataList; - } - - private void fillLoanSchedulePeriodData(List periods, List combinedDataList, - BigDecimal disbursementChargeAmount, BigDecimal waivedChargeAmount) { - // Process all collected data in chronological order - for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) { - LoanSchedulePeriodData periodData; - if (dataItem.isDisbursement()) { - // Process disbursement data - DisbursementData data = (DisbursementData) dataItem.getData(); - periodData = createLoanSchedulePeriodData(data, disbursementChargeAmount, waivedChargeAmount); - } else { - // Process capitalized income data - LoanTransactionRepaymentPeriodData data = (LoanTransactionRepaymentPeriodData) dataItem.getData(); - periodData = createLoanSchedulePeriodData(data); - } - - // Common processing for both data types - periods.add(periodData); - this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.add(periodData.getPrincipalDisbursed()); - } - } - - private BigDecimal calculateDisbursedAmount(List combinedDataList) { - BigDecimal disbursedAmount = BigDecimal.ZERO; - for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) { - if (dataItem.isDisbursement()) { - DisbursementData data = (DisbursementData) dataItem.getData(); - disbursedAmount = disbursedAmount.add(data.getPrincipal()); - } - } - return disbursedAmount; - } - - private int sortPeriodDataHolders(LoanSchedulePeriodDataWrapper item1, LoanSchedulePeriodDataWrapper item2) { - int dateComparison = item1.getDate().compareTo(item2.getDate()); - if (dateComparison == 0 && item1.isDisbursement() != item2.isDisbursement()) { - // If dates are equal, prioritize disbursement data - return item1.isDisbursement() ? -1 : 1; - } - return dateComparison; - } - - private LoanSchedulePeriodData createLoanSchedulePeriodData(final DisbursementData data, BigDecimal disbursementChargeAmount, - BigDecimal waivedChargeAmount) { - BigDecimal chargeAmount = data.getChargeAmount() == null ? disbursementChargeAmount - : disbursementChargeAmount.add(data.getChargeAmount()).subtract(waivedChargeAmount); - return LoanSchedulePeriodData.disbursementOnlyPeriod(data.disbursementDate(), data.getPrincipal(), chargeAmount, - data.isDisbursed()); - } - - private LoanSchedulePeriodData createLoanSchedulePeriodData(final LoanTransactionRepaymentPeriodData data) { - BigDecimal feeCharges = Objects.isNull(data.getFeeChargesPortion()) ? BigDecimal.ZERO : data.getFeeChargesPortion(); - return LoanSchedulePeriodData.disbursementOnlyPeriod(data.getDate(), data.getAmount(), feeCharges, !data.isReversed()); - } - - private boolean canAddDisbursementData(DisbursementData data, boolean isDueForDisbursement, boolean excludePastUnDisbursed) { - return (!excludePastUnDisbursed || data.isDisbursed() || !DateUtils.isBeforeBusinessDate(data.disbursementDate())) - && isDueForDisbursement; - } - - private boolean canAddCapitalizedIncomeData(LoanTransactionRepaymentPeriodData data, LocalDate fromDate, LocalDate dueDate) { - return !data.isReversed() && DateUtils.isDateInRangeFromInclusiveToExclusive(fromDate, dueDate, data.getDate()); - } - - } - protected static class LoanTransactionsMapper implements RowMapper { private final DatabaseSpecificSQLGenerator sqlGenerator; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRepaymentScheduleService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRepaymentScheduleService.java new file mode 100644 index 000000000..d39d0515e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRepaymentScheduleService.java @@ -0,0 +1,418 @@ +/** + * 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.portfolio.loanaccount.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.loanaccount.data.DisbursementData; +import org.apache.fineract.portfolio.loanaccount.data.LoanSchedulePeriodDataWrapper; +import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePeriodData; +import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LoanRepaymentScheduleService { + + private final LoanRepaymentScheduleInstallmentRepository loanRepaymentScheduleInstallmentRepository; + + public LoanScheduleData findLoanScheduleData(final Long loanId, final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, + Collection disbursementData, Collection capitalizedIncomeData, + boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) { + final List installments = this.loanRepaymentScheduleInstallmentRepository.findByLoanId(loanId); + + return extractLoanScheduleData(installments, repaymentScheduleRelatedLoanData, disbursementData, capitalizedIncomeData, + isInterestRecalculationEnabled, loanScheduleType); + } + + public LoanScheduleData extractLoanScheduleData(final List installments, + final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection disbursementData, + Collection capitalizedIncomeData, boolean isInterestRecalculationEnabled, + LoanScheduleType loanScheduleType) { + + final CurrencyData currency = repaymentScheduleRelatedLoanData.getCurrency(); + final DisbursementData disbursement = repaymentScheduleRelatedLoanData.disbursementData(); + final BigDecimal totalFeeChargesDueAtDisbursement = repaymentScheduleRelatedLoanData.getTotalFeeChargesAtDisbursement(); + LocalDate lastDueDate = disbursement.disbursementDate(); + BigDecimal outstandingLoanPrincipalBalance = disbursement.getPrincipal(); + boolean excludePastUnDisbursed = isInterestRecalculationEnabled; + + BigDecimal waivedChargeAmount = BigDecimal.ZERO; + for (DisbursementData disbursementDetail : disbursementData) { + waivedChargeAmount = waivedChargeAmount.add(disbursementDetail.getWaivedChargeAmount()); + } + final LoanSchedulePeriodData disbursementPeriod = LoanSchedulePeriodData.disbursementOnlyPeriod(disbursement.disbursementDate(), + disbursement.getPrincipal(), totalFeeChargesDueAtDisbursement, disbursement.isDisbursed()); + + final List periods = new ArrayList<>(); + final MonetaryCurrency monCurrency = new MonetaryCurrency(currency.getCode(), currency.getDecimalPlaces(), + currency.getInMultiplesOf()); + BigDecimal totalPrincipalDisbursed = BigDecimal.ZERO; + BigDecimal disbursementChargeAmount = totalFeeChargesDueAtDisbursement; + if (disbursementData.isEmpty()) { + periods.add(disbursementPeriod); + totalPrincipalDisbursed = Money.of(monCurrency, disbursement.getPrincipal()).getAmount(); + } else { + if (!disbursement.isDisbursed()) { + excludePastUnDisbursed = false; + } + for (DisbursementData data : disbursementData) { + if (data.getChargeAmount() != null) { + disbursementChargeAmount = disbursementChargeAmount.subtract(data.getChargeAmount()); + } + } + outstandingLoanPrincipalBalance = BigDecimal.ZERO; + } + + Money totalPrincipalExpected = Money.zero(monCurrency); + Money totalPrincipalPaid = Money.zero(monCurrency); + Money totalInterestCharged = Money.zero(monCurrency); + Money totalFeeChargesCharged = Money.zero(monCurrency); + Money totalPenaltyChargesCharged = Money.zero(monCurrency); + Money totalWaived = Money.zero(monCurrency); + Money totalWrittenOff = Money.zero(monCurrency); + Money totalRepaymentExpected = Money.zero(monCurrency); + Money totalRepayment = Money.zero(monCurrency); + Money totalPaidInAdvance = Money.zero(monCurrency); + Money totalPaidLate = Money.zero(monCurrency); + Money totalOutstanding = Money.zero(monCurrency); + Money totalCredits = Money.zero(monCurrency); + + // update totals with details of fees charged during disbursement + totalFeeChargesCharged = totalFeeChargesCharged.plus(disbursementPeriod.getFeeChargesDue().subtract(waivedChargeAmount)); + totalRepaymentExpected = totalRepaymentExpected.plus(disbursementPeriod.getFeeChargesDue()).minus(waivedChargeAmount); + totalRepayment = totalRepayment.plus(disbursementPeriod.getFeeChargesPaid()).minus(waivedChargeAmount); + totalOutstanding = totalOutstanding.plus(disbursementPeriod.getFeeChargesDue()).minus(disbursementPeriod.getFeeChargesPaid()); + + Integer loanTermInDays = 0; + Set disbursementPeriodIds = new HashSet<>(); + + for (LoanRepaymentScheduleInstallment installment : installments) { + final Integer period = installment.getInstallmentNumber(); + LocalDate fromDate = installment.getFromDate(); + final LocalDate dueDate = installment.getDueDate(); + final LocalDate obligationsMetOnDate = installment.getObligationsMetOnDate(); + final boolean complete = installment.isObligationsMet(); + + List combinedDataList = new ArrayList<>(); + combinedDataList.addAll(collectEligibleDisbursementData(loanScheduleType, disbursementData, fromDate, dueDate, + disbursementPeriodIds, disbursement, excludePastUnDisbursed)); + combinedDataList.addAll(collectEligibleCapitalizedIncomeData(capitalizedIncomeData, fromDate, dueDate, disbursementPeriodIds)); + combinedDataList.sort(this::sortPeriodDataHolders); + outstandingLoanPrincipalBalance = fillLoanSchedulePeriodData(periods, combinedDataList, disbursementChargeAmount, + waivedChargeAmount, outstandingLoanPrincipalBalance); + + BigDecimal disbursedAmount = calculateDisbursedAmount(combinedDataList); + + // Add the Charge back or Credits to the initial amount to avoid negative balance + final BigDecimal principalCredits = installment.getCreditedPrincipal() != null ? installment.getCreditedPrincipal() + : BigDecimal.ZERO; + final BigDecimal feeCredits = installment.getCreditedFee() != null ? installment.getCreditedFee() : BigDecimal.ZERO; + final BigDecimal penaltyCredits = installment.getCreditedPenalty() != null ? installment.getCreditedPenalty() : BigDecimal.ZERO; + final BigDecimal credits = principalCredits.add(feeCredits).add(penaltyCredits); + outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.add(principalCredits); + + totalPrincipalDisbursed = totalPrincipalDisbursed.add(disbursedAmount); + + Integer daysInPeriod = 0; + if (fromDate != null) { + daysInPeriod = DateUtils.getExactDifferenceInDays(fromDate, dueDate); + loanTermInDays = loanTermInDays + daysInPeriod; + } + + final BigDecimal principalDue = installment.getPrincipal() != null ? installment.getPrincipal() : BigDecimal.ZERO; + totalPrincipalExpected = totalPrincipalExpected.plus(principalDue); + final BigDecimal principalPaid = installment.getPrincipalCompleted() != null ? installment.getPrincipalCompleted() + : BigDecimal.ZERO; + totalPrincipalPaid = totalPrincipalPaid.plus(principalPaid); + final BigDecimal principalWrittenOff = installment.getPrincipalWrittenOff() != null ? installment.getPrincipalWrittenOff() + : BigDecimal.ZERO; + + final BigDecimal principalOutstanding = principalDue.subtract(principalPaid).subtract(principalWrittenOff); + + final BigDecimal interestExpectedDue = installment.getInterestCharged() != null ? installment.getInterestCharged() + : BigDecimal.ZERO; + totalInterestCharged = totalInterestCharged.plus(interestExpectedDue); + final BigDecimal interestPaid = installment.getInterestPaid() != null ? installment.getInterestPaid() : BigDecimal.ZERO; + final BigDecimal interestWaived = installment.getInterestWaived() != null ? installment.getInterestWaived() : BigDecimal.ZERO; + final BigDecimal interestWrittenOff = installment.getInterestWrittenOff() != null ? installment.getInterestWrittenOff() + : BigDecimal.ZERO; + final BigDecimal accrualInterest = installment.getInterestAccrued() != null ? installment.getInterestAccrued() + : BigDecimal.ZERO; + + final BigDecimal interestActualDue = interestExpectedDue.subtract(interestWaived).subtract(interestWrittenOff); + final BigDecimal interestOutstanding = interestActualDue.subtract(interestPaid); + + final BigDecimal feeChargesExpectedDue = installment.getFeeChargesCharged() != null ? installment.getFeeChargesCharged() + : BigDecimal.ZERO; + totalFeeChargesCharged = totalFeeChargesCharged.plus(feeChargesExpectedDue); + final BigDecimal feeChargesPaid = installment.getFeeChargesPaid() != null ? installment.getFeeChargesPaid() : BigDecimal.ZERO; + final BigDecimal feeChargesWaived = installment.getFeeChargesWaived() != null ? installment.getFeeChargesWaived() + : BigDecimal.ZERO; + final BigDecimal feeChargesWrittenOff = installment.getFeeChargesWrittenOff() != null ? installment.getFeeChargesWrittenOff() + : BigDecimal.ZERO; + + final BigDecimal feeChargesActualDue = feeChargesExpectedDue.subtract(feeChargesWaived).subtract(feeChargesWrittenOff); + final BigDecimal feeChargesOutstanding = feeChargesActualDue.subtract(feeChargesPaid); + + final BigDecimal penaltyChargesExpectedDue = installment.getPenaltyCharges() != null ? installment.getPenaltyCharges() + : BigDecimal.ZERO; + totalPenaltyChargesCharged = totalPenaltyChargesCharged.plus(penaltyChargesExpectedDue); + final BigDecimal penaltyChargesPaid = installment.getPenaltyChargesPaid() != null ? installment.getPenaltyChargesPaid() + : BigDecimal.ZERO; + final BigDecimal penaltyChargesWaived = installment.getPenaltyChargesWaived() != null ? installment.getPenaltyChargesWaived() + : BigDecimal.ZERO; + final BigDecimal penaltyChargesWrittenOff = installment.getPenaltyChargesWrittenOff() != null + ? installment.getPenaltyChargesWrittenOff() + : BigDecimal.ZERO; + + final BigDecimal totalPaidInAdvanceForPeriod = installment.getTotalPaidInAdvance() != null ? installment.getTotalPaidInAdvance() + : BigDecimal.ZERO; + final BigDecimal totalPaidLateForPeriod = installment.getTotalPaidLate() != null ? installment.getTotalPaidLate() + : BigDecimal.ZERO; + + final BigDecimal penaltyChargesActualDue = penaltyChargesExpectedDue.subtract(penaltyChargesWaived) + .subtract(penaltyChargesWrittenOff); + final BigDecimal penaltyChargesOutstanding = penaltyChargesActualDue.subtract(penaltyChargesPaid); + + final BigDecimal totalExpectedCostOfLoanForPeriod = interestExpectedDue.add(feeChargesExpectedDue) + .add(penaltyChargesExpectedDue); + + final BigDecimal totalDueForPeriod = principalDue.add(totalExpectedCostOfLoanForPeriod); + final BigDecimal totalPaidForPeriod = principalPaid.add(interestPaid).add(feeChargesPaid).add(penaltyChargesPaid); + final BigDecimal totalWaivedForPeriod = interestWaived.add(feeChargesWaived).add(penaltyChargesWaived); + totalWaived = totalWaived.plus(totalWaivedForPeriod); + final BigDecimal totalWrittenOffForPeriod = principalWrittenOff.add(interestWrittenOff).add(feeChargesWrittenOff) + .add(penaltyChargesWrittenOff); + totalWrittenOff = totalWrittenOff.plus(totalWrittenOffForPeriod); + + final BigDecimal totalOutstandingForPeriod = principalOutstanding.add(interestOutstanding).add(feeChargesOutstanding) + .add(penaltyChargesOutstanding); + + totalRepaymentExpected = totalRepaymentExpected.plus(totalDueForPeriod); + totalRepayment = totalRepayment.plus(totalPaidForPeriod); + totalPaidInAdvance = totalPaidInAdvance.plus(totalPaidInAdvanceForPeriod); + totalPaidLate = totalPaidLate.plus(totalPaidLateForPeriod); + totalOutstanding = totalOutstanding.plus(totalOutstandingForPeriod); + totalCredits = totalCredits.add(credits); + + if (fromDate == null) { + fromDate = lastDueDate; + } + + BigDecimal outstandingPrincipalBalanceOfLoan = outstandingLoanPrincipalBalance.subtract(principalDue); + + // update based on current period values + lastDueDate = dueDate; + outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.subtract(principalDue); + + final boolean isDownPayment = installment.isDownPayment(); + + LoanSchedulePeriodData periodData; + + periodData = LoanSchedulePeriodData.periodWithPayments(period, fromDate, dueDate, obligationsMetOnDate, complete, principalDue, + principalPaid, principalWrittenOff, principalOutstanding, outstandingPrincipalBalanceOfLoan, interestExpectedDue, + interestPaid, interestWaived, interestWrittenOff, interestOutstanding, feeChargesExpectedDue, feeChargesPaid, + feeChargesWaived, feeChargesWrittenOff, feeChargesOutstanding, penaltyChargesExpectedDue, penaltyChargesPaid, + penaltyChargesWaived, penaltyChargesWrittenOff, penaltyChargesOutstanding, totalPaidForPeriod, + totalPaidInAdvanceForPeriod, totalPaidLateForPeriod, totalWaivedForPeriod, totalWrittenOffForPeriod, credits, + isDownPayment, accrualInterest); + + periods.add(periodData); + } + + return new LoanScheduleData(currency, periods, loanTermInDays, totalPrincipalDisbursed, totalPrincipalExpected.getAmount(), + totalPrincipalPaid.getAmount(), totalInterestCharged.getAmount(), totalFeeChargesCharged.getAmount(), + totalPenaltyChargesCharged.getAmount(), totalWaived.getAmount(), totalWrittenOff.getAmount(), + totalRepaymentExpected.getAmount(), totalRepayment.getAmount(), totalPaidInAdvance.getAmount(), totalPaidLate.getAmount(), + totalOutstanding.getAmount(), totalCredits.getAmount()); + } + + private List collectEligibleDisbursementData(LoanScheduleType loanScheduleType, + Collection disbursementData, LocalDate fromDate, LocalDate dueDate, Set disbursementPeriodIds, + DisbursementData mainDisbursement, boolean excludePastUnDisbursed) { + List disbursementDataList = new ArrayList<>(); + + boolean hasMultipleTranchesOnSameDate = hasMultipleTranchesOnSameDate(disbursementData); + + if (hasMultipleTranchesOnSameDate) { + Map> disbursementsByDate = new HashMap<>(); + + for (final DisbursementData data : disbursementData) { + boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate); + boolean isEligible = ((fromDate.equals(mainDisbursement.disbursementDate()) && data.disbursementDate().equals(fromDate)) + || (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate)) + || canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed)) + && !disbursementPeriodIds.contains(data.getId()); + + if (isEligible) { + disbursementsByDate.computeIfAbsent(data.disbursementDate(), k -> new ArrayList<>()).add(data); + disbursementPeriodIds.add(data.getId()); + } + } + + for (Map.Entry> entry : disbursementsByDate.entrySet()) { + List sameDateDisbursements = entry.getValue(); + + if (sameDateDisbursements.size() > 1) { + List disbursedTranches = sameDateDisbursements.stream().filter(DisbursementData::isDisbursed) + .collect(Collectors.toList()); + + if (!disbursedTranches.isEmpty()) { + for (DisbursementData data : disbursedTranches) { + disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); + } + } else { + for (DisbursementData data : sameDateDisbursements) { + disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); + } + } + } else { + DisbursementData data = sameDateDisbursements.get(0); + disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); + } + } + } else { + for (final DisbursementData data : disbursementData) { + boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate); + boolean isEligible = ((fromDate.equals(mainDisbursement.disbursementDate()) && data.disbursementDate().equals(fromDate)) + || (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate)) + || canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed)) + && !disbursementPeriodIds.contains(data.getId()); + + if (isEligible) { + disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true)); + disbursementPeriodIds.add(data.getId()); + } + } + } + + return disbursementDataList; + } + + private boolean hasMultipleTranchesOnSameDate(Collection disbursementData) { + if (disbursementData == null || disbursementData.size() <= 1) { + return false; + } + return disbursementData.stream().collect(Collectors.groupingBy(DisbursementData::disbursementDate, Collectors.counting())).values() + .stream().anyMatch(count -> count > 1); + } + + private List collectEligibleCapitalizedIncomeData( + Collection capitalizedIncomeData, LocalDate fromDate, LocalDate dueDate, + Set disbursementPeriodIds) { + List capitalizedIncomeDataList = new ArrayList<>(); + // Collect eligible capitalized income data + for (LoanTransactionRepaymentPeriodData data : capitalizedIncomeData) { + boolean isEligible = canAddCapitalizedIncomeData(data, fromDate, dueDate) + && !disbursementPeriodIds.contains(data.getTransactionId()); + + if (isEligible) { + capitalizedIncomeDataList.add(new LoanSchedulePeriodDataWrapper(data, data.getDate(), false)); + disbursementPeriodIds.add(data.getTransactionId()); + } + } + return capitalizedIncomeDataList; + } + + private BigDecimal fillLoanSchedulePeriodData(List periods, + List combinedDataList, BigDecimal disbursementChargeAmount, BigDecimal waivedChargeAmount, + BigDecimal outstandingLoanPrincipalBalance) { + // Process all collected data in chronological order + for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) { + LoanSchedulePeriodData periodData; + if (dataItem.isDisbursement()) { + // Process disbursement data + DisbursementData data = (DisbursementData) dataItem.getData(); + periodData = createLoanSchedulePeriodData(data, disbursementChargeAmount, waivedChargeAmount); + } else { + // Process capitalized income data + LoanTransactionRepaymentPeriodData data = (LoanTransactionRepaymentPeriodData) dataItem.getData(); + periodData = createLoanSchedulePeriodData(data); + } + + // Common processing for both data types + periods.add(periodData); + outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.add(periodData.getPrincipalDisbursed()); + } + return outstandingLoanPrincipalBalance; + } + + private BigDecimal calculateDisbursedAmount(List combinedDataList) { + BigDecimal disbursedAmount = BigDecimal.ZERO; + for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) { + if (dataItem.isDisbursement()) { + DisbursementData data = (DisbursementData) dataItem.getData(); + disbursedAmount = disbursedAmount.add(data.getPrincipal()); + } + } + return disbursedAmount; + } + + private int sortPeriodDataHolders(LoanSchedulePeriodDataWrapper item1, LoanSchedulePeriodDataWrapper item2) { + int dateComparison = item1.getDate().compareTo(item2.getDate()); + if (dateComparison == 0 && item1.isDisbursement() != item2.isDisbursement()) { + // If dates are equal, prioritize disbursement data + return item1.isDisbursement() ? -1 : 1; + } + return dateComparison; + } + + private LoanSchedulePeriodData createLoanSchedulePeriodData(final DisbursementData data, BigDecimal disbursementChargeAmount, + BigDecimal waivedChargeAmount) { + BigDecimal chargeAmount = data.getChargeAmount() == null ? disbursementChargeAmount + : disbursementChargeAmount.add(data.getChargeAmount()).subtract(waivedChargeAmount); + return LoanSchedulePeriodData.disbursementOnlyPeriod(data.disbursementDate(), data.getPrincipal(), chargeAmount, + data.isDisbursed()); + } + + private LoanSchedulePeriodData createLoanSchedulePeriodData(final LoanTransactionRepaymentPeriodData data) { + BigDecimal feeCharges = Objects.isNull(data.getFeeChargesPortion()) ? BigDecimal.ZERO : data.getFeeChargesPortion(); + return LoanSchedulePeriodData.disbursementOnlyPeriod(data.getDate(), data.getAmount(), feeCharges, !data.isReversed()); + } + + private boolean canAddDisbursementData(DisbursementData data, boolean isDueForDisbursement, boolean excludePastUnDisbursed) { + return (!excludePastUnDisbursed || data.isDisbursed() || !DateUtils.isBeforeBusinessDate(data.disbursementDate())) + && isDueForDisbursement; + } + + private boolean canAddCapitalizedIncomeData(LoanTransactionRepaymentPeriodData data, LocalDate fromDate, LocalDate dueDate) { + return !data.isReversed() && DateUtils.isDateInRangeFromInclusiveToExclusive(fromDate, dueDate, data.getDate()); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java index 80139a25f..82890eccf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java @@ -90,6 +90,14 @@ public class ReprocessLoanTransactionsServiceImpl implements ReprocessLoanTransa handleChangedDetail(changedTransactionDetail); } + @Override + public void reprocessTransactionsWithoutChecks(final Loan loan, final LocalDate transactionDate, + final List newTransactions) { + final List transactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan); + transactions.addAll(newTransactions); + reprocessTransactionsAndFetchChangedTransactions(loan, transactions); + } + @Override public void processPostDisbursementTransactions(final Loan loan) { loanTransactionProcessingService.processPostDisbursementTransactions(loan).ifPresent(this::handleChangedDetail); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java similarity index 65% rename from fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingServiceImpl.java rename to fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java index ba9703f4e..fa0931e78 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java @@ -22,10 +22,13 @@ import static java.math.BigDecimal.ZERO; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.Collection; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; @@ -33,6 +36,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.event.business.domain.loan.reaging.LoanReAgeBusinessEvent; @@ -40,14 +44,20 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.reaging.Loa import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.reaging.LoanReAgeTransactionBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.reaging.LoanUndoReAgeTransactionBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants; import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants; +import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest; +import org.apache.fineract.portfolio.loanaccount.data.DisbursementData; +import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData; import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeInterestHandlingType; @@ -56,8 +66,12 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.Loa import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; +import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData; +import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository; import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; +import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; +import org.apache.fineract.portfolio.loanaccount.service.LoanRepaymentScheduleService; import org.apache.fineract.portfolio.loanaccount.service.LoanScheduleService; import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService; import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService; @@ -69,7 +83,7 @@ import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Transactional -public class LoanReAgingServiceImpl { +public class LoanReAgingService { private final LoanAssembler loanAssembler; private final LoanReAgingValidator reAgingValidator; @@ -83,40 +97,22 @@ public class LoanReAgingServiceImpl { private final LoanScheduleService loanScheduleService; private final ReprocessLoanTransactionsService reprocessLoanTransactionsService; private final CodeValueRepository codeValueRepository; + private final LoanRepaymentScheduleService loanRepaymentScheduleService; + private final LoanReadPlatformService loanReadPlatformService; + private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository; - public CommandProcessingResult reAge(Long loanId, JsonCommand command) { - Loan loan = loanAssembler.assembleFrom(loanId); + public CommandProcessingResult reAge(final Long loanId, final JsonCommand command) { + final Loan loan = loanAssembler.assembleFrom(loanId); reAgingValidator.validateReAge(loan, command); - Map changes = new LinkedHashMap<>(); + final LoanTransaction reAgeTransaction = createReAgeTransaction(loan, command); + loanTransactionRepository.saveAndFlush(reAgeTransaction); + processReAgeTransaction(loan, reAgeTransaction, true); + loan.updateLoanScheduleDependentDerivedFields(); + + final Map changes = new LinkedHashMap<>(); changes.put(LoanReAgingApiConstants.localeParameterName, command.locale()); changes.put(LoanReAgingApiConstants.dateFormatParameterName, command.dateFormat()); - - LoanTransaction reAgeTransaction = createReAgeTransaction(loan, command); - LoanReAgeParameter reAgeParameter = createReAgeParameter(reAgeTransaction, command); - reAgeTransaction.setLoanReAgeParameter(reAgeParameter); - loanTransactionRepository.saveAndFlush(reAgeTransaction); - final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory - .determineProcessor(loan.transactionProcessingStrategy()); - if (reAgeTransaction.getTransactionDate().isBefore(reAgeTransaction.getSubmittedOnDate()) - && !loan.isInterestBearingAndInterestRecalculationEnabled()) { - reprocessLoanTransactionsService.reprocessTransactionsWithPostTransactionChecks(loan, reAgeTransaction.getTransactionDate()); - } else if (loan.isInterestBearingAndInterestRecalculationEnabled()) { - if (loan.isProgressiveSchedule() && ((loan.hasChargeOffTransaction() && loan.hasAccelerateChargeOffStrategy()) - || loan.hasContractTerminationTransaction() - || (loan.isInterestRecalculationEnabled() && loan.hasReAgingTransaction()))) { - final ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null); - loanScheduleService.regenerateRepaymentSchedule(loan, scheduleGeneratorDTO); - } - final List loanTransactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan); - loanTransactions.add(reAgeTransaction); - reprocessLoanTransactionsService.reprocessParticularTransactions(loan, loanTransactions); - } else { - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction, - new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), - new MoneyHolder(loan.getTotalOverpaidAsMoney()), null)); - } - loan.updateLoanScheduleDependentDerivedFields(); persistNote(loan, command, changes); // delinquency recalculation will be triggered by the event in a decoupled way via a listener @@ -133,9 +129,38 @@ public class LoanReAgingServiceImpl { .with(changes).build(); } + @Transactional(readOnly = true) + public LoanScheduleData previewReAge(final Long loanId, final String loanExternalId, final ReAgePreviewRequest reAgePreviewRequest) { + final Loan loan = loanId != null ? loanAssembler.assembleFrom(loanId) + : loanAssembler.assembleFrom(ExternalIdFactory.produce(loanExternalId), false); + return previewReAge(loan, reAgePreviewRequest); + } + + private LoanScheduleData previewReAge(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) { + reAgingValidator.validateReAge(loan, reAgePreviewRequest); + + final LoanTransaction reAgeTransaction = createReAgeTransactionFromPreviewRequest(loan, reAgePreviewRequest); + processReAgeTransaction(loan, reAgeTransaction, false); + loan.updateLoanScheduleDependentDerivedFields(); + + final CurrencyData currencyData = new CurrencyData(loan.getCurrencyCode(), null, loan.getCurrency().getDigitsAfterDecimal(), + loan.getCurrency().getInMultiplesOf(), null, null); + final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData = new RepaymentScheduleRelatedLoanData( + loan.getDisbursementDate(), loan.getDisbursementDate(), currencyData, loan.getPrincipal().getAmount(), + loan.getInArrearsTolerance().getAmount(), ZERO); + final Collection disbursementData = loanReadPlatformService.retrieveLoanDisbursementDetails(loan.getId()); + final Collection capitalizedIncomeData = loanCapitalizedIncomeBalanceRepository + .findRepaymentPeriodDataByLoanId(loan.getId()); + final List sortedInstallments = loan.getRepaymentScheduleInstallments().stream() + .sorted(Comparator.comparingInt(LoanRepaymentScheduleInstallment::getInstallmentNumber)).collect(Collectors.toList()); + + return loanRepaymentScheduleService.extractLoanScheduleData(sortedInstallments, repaymentScheduleRelatedLoanData, disbursementData, + capitalizedIncomeData, loan.isInterestRecalculationEnabled(), loan.getLoanProductRelatedDetail().getLoanScheduleType()); + } + public CommandProcessingResult undoReAge(Long loanId, JsonCommand command) { Loan loan = loanAssembler.assembleFrom(loanId); - reAgingValidator.validateUndoReAge(loan, command); + reAgingValidator.validateUndoReAge(loan); Map changes = new LinkedHashMap<>(); changes.put(LoanReAgingApiConstants.localeParameterName, command.locale()); @@ -170,6 +195,35 @@ public class LoanReAgingServiceImpl { .with(changes).build(); } + private void processReAgeTransaction(final Loan loan, final LoanTransaction reAgeTransaction, final boolean withPostTransactionChecks) { + final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory + .determineProcessor(loan.transactionProcessingStrategy()); + if (reAgeTransaction.getTransactionDate().isBefore(reAgeTransaction.getSubmittedOnDate()) + && !loan.isInterestBearingAndInterestRecalculationEnabled()) { + if (withPostTransactionChecks) { + reprocessLoanTransactionsService.reprocessTransactionsWithPostTransactionChecks(loan, + reAgeTransaction.getTransactionDate()); + } else { + reprocessLoanTransactionsService.reprocessTransactionsWithoutChecks(loan, reAgeTransaction.getTransactionDate(), + List.of(reAgeTransaction)); + } + } else if (loan.isInterestBearingAndInterestRecalculationEnabled()) { + if (loan.isProgressiveSchedule() && ((loan.hasChargeOffTransaction() && loan.hasAccelerateChargeOffStrategy()) + || loan.hasContractTerminationTransaction() + || (loan.isInterestRecalculationEnabled() && loan.hasReAgingTransaction()))) { + final ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null); + loanScheduleService.regenerateRepaymentSchedule(loan, scheduleGeneratorDTO); + } + final List loanTransactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan); + loanTransactions.add(reAgeTransaction); + reprocessLoanTransactionsService.reprocessParticularTransactions(loan, loanTransactions); + } else { + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction, + new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), + new MoneyHolder(loan.getTotalOverpaidAsMoney()), null)); + } + } + private void reverseReAgeTransaction(LoanTransaction reAgeTransaction, JsonCommand command) { ExternalId reversalExternalId = externalIdFactory.createFromCommand(command, LoanReAgingApiConstants.externalIdParameterName); loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(reAgeTransaction.getLoan(), reAgeTransaction, @@ -199,8 +253,13 @@ public class LoanReAgingServiceImpl { Money txPrincipal = loan.getTotalPrincipalOutstandingUntil(transactionDate); BigDecimal txPrincipalAmount = txPrincipal.getAmount(); - return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate, txPrincipalAmount, txPrincipalAmount, - ZERO, ZERO, ZERO, null, false, null, txExternalId); + final LoanTransaction reAgeTransaction = new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate, + txPrincipalAmount, txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, txExternalId); + + final LoanReAgeParameter reAgeParameter = createReAgeParameter(reAgeTransaction, command); + reAgeTransaction.setLoanReAgeParameter(reAgeParameter); + + return reAgeTransaction; } private LoanReAgeParameter createReAgeParameter(LoanTransaction reAgeTransaction, JsonCommand command) { @@ -235,4 +294,43 @@ public class LoanReAgingServiceImpl { this.noteRepository.saveAndFlush(newNote); } } + + private LoanTransaction createReAgeTransactionFromPreviewRequest(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) { + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + final Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale()) + : Locale.getDefault(); + final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate, + reAgePreviewRequest.getDateFormat(), locale); + if (transactionDate.isAfter(startDate)) { + transactionDate = startDate; + } + + final Money txPrincipal = loan.getTotalPrincipalOutstandingUntil(transactionDate); + final BigDecimal txPrincipalAmount = txPrincipal.getAmount(); + + final LoanTransaction reAgeTransaction = new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate, + txPrincipalAmount, txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, null); + + final LoanReAgeParameter reAgeParameter = createReAgeParameterFromPreviewRequest(reAgeTransaction, reAgePreviewRequest); + reAgeTransaction.setLoanReAgeParameter(reAgeParameter); + + return reAgeTransaction; + } + + private LoanReAgeParameter createReAgeParameterFromPreviewRequest(final LoanTransaction reAgeTransaction, + final ReAgePreviewRequest reAgePreviewRequest) { + final PeriodFrequencyType periodFrequencyType = PeriodFrequencyType.valueOf(reAgePreviewRequest.getFrequencyType()); + final Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale()) + : Locale.getDefault(); + final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate, + reAgePreviewRequest.getDateFormat(), locale); + final Integer numberOfInstallments = reAgePreviewRequest.getNumberOfInstallments(); + final Integer periodFrequencyNumber = reAgePreviewRequest.getFrequencyNumber(); + + final LoanReAgeInterestHandlingType reAgeInterestHandlingType = LoanReAgeInterestHandlingType.DEFAULT; + + return new LoanReAgeParameter(reAgeTransaction, periodFrequencyType, periodFrequencyNumber, startDate, numberOfInstallments, + reAgeInterestHandlingType, null); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java index 868bfaa7c..a2ce20da2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java @@ -24,6 +24,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.codes.domain.CodeValue; @@ -33,8 +34,10 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants; import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants; +import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; @@ -57,6 +60,12 @@ public class LoanReAgingValidator { validateReAgeOutstandingBalance(loan, command); } + public void validateReAge(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) { + validateReAgeRequest(loan, reAgePreviewRequest); + validateReAgeBusinessRules(loan); + validateReAgeOutstandingBalance(loan, reAgePreviewRequest); + } + private void validateReAgeRequest(Loan loan, JsonCommand command) { List dataValidationErrors = new ArrayList<>(); DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.reAge"); @@ -145,7 +154,7 @@ public class LoanReAgingValidator { } } - public void validateUndoReAge(Loan loan, JsonCommand command) { + public void validateUndoReAge(Loan loan) { validateUndoReAgeBusinessRules(loan); } @@ -181,4 +190,42 @@ public class LoanReAgingValidator { } } + private void validateReAgeRequest(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.reAge"); + + final Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale()) + : Locale.getDefault(); + final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate, + reAgePreviewRequest.getDateFormat(), locale); + + if (loan.isProgressiveSchedule()) { + baseDataValidator.reset().parameter(LoanReAgingApiConstants.startDate).value(startDate) + .validateDateAfterOrEqual(loan.getDisbursementDate()); + } else { + baseDataValidator.reset().parameter(LoanReAgingApiConstants.startDate).value(startDate) + .validateDateAfter(loan.getMaturityDate()); + } + + throwExceptionIfValidationErrorsExist(dataValidationErrors); + } + + private void validateReAgeOutstandingBalance(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) { + final LocalDate businessDate = getBusinessLocalDate(); + Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale()) + : Locale.getDefault(); + final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate, + reAgePreviewRequest.getDateFormat(), locale); + + final boolean isBackdated = businessDate.isAfter(startDate); + if (isBackdated) { + return; + } + + if (loan.getSummary().getTotalPrincipalOutstanding().compareTo(java.math.BigDecimal.ZERO) == 0) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.reage.no.outstanding.balance.to.reage", + "Loan cannot be re-aged as there are no outstanding balances to be re-aged", loan.getId()); + } + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index 638d5608d..39cd264df 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -152,6 +152,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanOfficerService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.LoanRefundService; +import org.apache.fineract.portfolio.loanaccount.service.LoanRepaymentScheduleService; import org.apache.fineract.portfolio.loanaccount.service.LoanScheduleService; import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformServiceImpl; @@ -350,7 +351,8 @@ public class LoanAccountConfiguration { LoanTransactionProcessingService loanTransactionProcessingService, LoanBalanceService loanBalanceService, LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository, LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository, - @Lazy InterestRefundServiceDelegate interestRefundServiceDelegate, LoanMaximumAmountCalculator loanMaximumAmountCalculator) { + @Lazy InterestRefundServiceDelegate interestRefundServiceDelegate, LoanMaximumAmountCalculator loanMaximumAmountCalculator, + LoanRepaymentScheduleService loanRepaymentScheduleService) { return new LoanReadPlatformServiceImpl(jdbcTemplate, context, loanRepositoryWrapper, applicationCurrencyRepository, loanProductReadPlatformService, clientReadPlatformService, groupReadPlatformService, loanDropdownReadPlatformService, fundReadPlatformService, chargeReadPlatformService, codeValueReadPlatformService, calendarReadPlatformService, @@ -359,7 +361,7 @@ public class LoanAccountConfiguration { delinquencyReadPlatformService, loanTransactionRepository, loanChargePaidByReadService, loanTransactionRelationReadService, loanForeclosureValidator, loanTransactionMapper, loanTransactionProcessingService, loanBalanceService, loanCapitalizedIncomeBalanceRepository, loanBuyDownFeeBalanceRepository, interestRefundServiceDelegate, - loanMaximumAmountCalculator); + loanMaximumAmountCalculator, loanRepaymentScheduleService); } @Bean diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/api/ReAgePreviewRequestValidationTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/api/ReAgePreviewRequestValidationTest.java new file mode 100644 index 000000000..59736baca --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/api/ReAgePreviewRequestValidationTest.java @@ -0,0 +1,361 @@ +/** + * 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.portfolio.loanaccount.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest; +import org.hibernate.validator.HibernateValidator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ContextConfiguration; + +@Slf4j +@SpringBootTest +@ContextConfiguration(classes = { ReAgePreviewRequestValidationTest.TestConfig.class }) +class ReAgePreviewRequestValidationTest { + + @Configuration + @Import({ MessageSourceAutoConfiguration.class }) + static class TestConfig { + + @Bean + public Validator validator() { + return Validation.byProvider(HibernateValidator.class).configure().buildValidatorFactory().getValidator(); + } + } + + @Autowired + private Validator validator; + + @Test + void invalidAllBlank() { + var params = ReAgePreviewRequest.builder().build(); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(6); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber")); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType")); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate")); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments")); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("dateFormat")); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale")); + } + + @Test + void invalidFrequencyNumberNull() { + var params = validParams(); + params.setFrequencyNumber(null); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyNumber' is mandatory.")); + } + + @Test + void invalidFrequencyNumberZero() { + var params = validParams(); + params.setFrequencyNumber(0); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyNumber' must be at least 1.")); + } + + @Test + void invalidFrequencyNumberNegative() { + var params = validParams(); + params.setFrequencyNumber(-1); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyNumber' must be at least 1.")); + } + + @Test + void invalidFrequencyTypeNull() { + var params = validParams(); + params.setFrequencyType(null); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyType' is mandatory.")); + } + + @Test + void invalidFrequencyTypeEmpty() { + var params = validParams(); + params.setFrequencyType(""); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyType' is mandatory.")); + } + + @Test + void invalidFrequencyTypeBlank() { + var params = validParams(); + params.setFrequencyType(" "); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType")); + } + + @Test + void invalidFrequencyTypeInvalidEnum() { + var params = validParams(); + params.setFrequencyType("NOT_A_VALID_ENUM"); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType")); + assertThat(errors).anyMatch(e -> e.getMessage() + .equals("The parameter 'frequencyType' must be valid PeriodFrequencyType value. Provided value: 'NOT_A_VALID_ENUM'.")); + } + + @Test + void invalidStartDateNull() { + var params = validParams(); + params.setStartDate(null); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'startDate' is mandatory.")); + } + + @Test + void invalidStartDateEmpty() { + var params = validParams(); + params.setStartDate(""); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'startDate' is mandatory.")); + } + + @Test + void invalidStartDateBlank() { + var params = validParams(); + params.setStartDate(" "); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate")); + } + + @Test + void invalidStartDateFormat() { + var params = validParams(); + params.setStartDate("2025-05-12"); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getMessage().equals("Wrong local date fields.")); + } + + @Test + void invalidNumberOfInstallmentsNull() { + var params = validParams(); + params.setNumberOfInstallments(null); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'numberOfInstallments' is mandatory.")); + } + + @Test + void invalidNumberOfInstallmentsZero() { + var params = validParams(); + params.setNumberOfInstallments(0); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'numberOfInstallments' must be at least 1.")); + } + + @Test + void invalidNumberOfInstallmentsNegative() { + var params = validParams(); + params.setNumberOfInstallments(-1); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'numberOfInstallments' must be at least 1.")); + } + + @Test + void invalidDateFormatNull() { + var params = validParams(); + params.setDateFormat(null); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("dateFormat")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'dateFormat' is mandatory.")); + } + + @Test + void invalidDateFormatEmpty() { + var params = validParams(); + params.setDateFormat(""); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("dateFormat")); + } + + @Test + void invalidDateFormatMismatch() { + var params = validParams(); + params.setDateFormat("yyyy-MM-dd"); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getMessage().equals("Wrong local date fields.")); + } + + @Test + void invalidLocaleNull() { + var params = validParams(); + params.setLocale(null); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale")); + assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'locale' is mandatory.")); + } + + @Test + void invalidLocaleEmpty() { + var params = validParams(); + params.setLocale(""); + + var errors = validator.validate(params); + + assertThat(errors).hasSizeGreaterThanOrEqualTo(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale")); + } + + @Test + void invalidLocaleInvalidFormat() { + var params = validParams(); + params.setLocale("invalid-locale"); + + var errors = validator.validate(params); + + assertThat(errors).hasSize(1); + assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale")); + } + + @Test + void validAllParams() { + var params = validParams(); + + var errors = validator.validate(params); + + assertThat(errors).isEmpty(); + } + + @Test + void validParamsWithDaysFrequency() { + var params = validParams(); + params.setFrequencyType("DAYS"); + params.setFrequencyNumber(30); + + var errors = validator.validate(params); + + assertThat(errors).isEmpty(); + } + + @Test + void validParamsWithWeeksFrequency() { + var params = validParams(); + params.setFrequencyType("WEEKS"); + params.setFrequencyNumber(4); + + var errors = validator.validate(params); + + assertThat(errors).isEmpty(); + } + + @Test + void validParamsWithYearsFrequency() { + var params = validParams(); + params.setFrequencyType("YEARS"); + params.setFrequencyNumber(1); + + var errors = validator.validate(params); + + assertThat(errors).isEmpty(); + } + + @Test + void validParamsWithAlternativeLocale() { + var params = validParams(); + params.setLocale("en_US"); + + var errors = validator.validate(params); + + assertThat(errors).isEmpty(); + } + + private ReAgePreviewRequest validParams() { + return ReAgePreviewRequest.builder().frequencyNumber(1).frequencyType("MONTHS").startDate("12-05-2025").numberOfInstallments(6) + .dateFormat("dd-MM-yyyy").locale("en").build(); + } + +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java index 9695c83c7..969b950b8 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java @@ -113,7 +113,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.externalId.exceeds.max.length"); } @@ -137,7 +137,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.startDate.cannot.be.blank"); } @@ -161,7 +161,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.frequencyType.cannot.be.blank"); } @@ -185,7 +185,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.frequencyNumber.cannot.be.blank"); } @@ -210,7 +210,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.frequencyNumber.not.greater.than.zero"); } @@ -234,7 +234,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.numberOfInstallments.cannot.be.blank"); } @@ -259,7 +259,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.numberOfInstallments.not.greater.than.zero"); } @@ -284,7 +284,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.numberOfInstallments.not.greater.than.zero"); } @@ -301,7 +301,7 @@ class LoanReAgingValidatorTest { // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); - assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode()) + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) .isEqualTo("validation.msg.loan.reAge.startDate.is.less.than.date"); } @@ -371,10 +371,9 @@ class LoanReAgingValidatorTest { List transactions = List.of(loanTransaction(LoanTransactionType.DISBURSEMENT, actualDate.minusDays(3))); Loan loan = loan(); given(loan.getLoanTransactions()).willReturn(transactions); - JsonCommand command = jsonCommand(); // when GeneralPlatformDomainRuleException result = assertThrows(GeneralPlatformDomainRuleException.class, - () -> underTest.validateUndoReAge(loan, command)); + () -> underTest.validateUndoReAge(loan)); // then assertThat(result).isNotNull(); assertThat(result.getGlobalisationMessageCode()).isEqualTo("error.msg.loan.reage.reaging.transaction.missing"); @@ -390,9 +389,8 @@ class LoanReAgingValidatorTest { OffsetDateTime.of(actualDate, LocalTime.of(9, 0), ZoneOffset.UTC))); Loan loan = loan(); given(loan.getLoanTransactions()).willReturn(transactions); - JsonCommand command = jsonCommand(); // when - underTest.validateUndoReAge(loan, command); + underTest.validateUndoReAge(loan); // then no exception thrown } diff --git a/fineract-validation/src/main/resources/ValidationMessages.properties b/fineract-validation/src/main/resources/ValidationMessages.properties index d8d01b20a..40cbafc56 100644 --- a/fineract-validation/src/main/resources/ValidationMessages.properties +++ b/fineract-validation/src/main/resources/ValidationMessages.properties @@ -43,3 +43,13 @@ org.apache.fineract.cache.cache-type.not-null=The parameter 'cacheType' is manda org.apache.fineract.organisation.monetary.currencies.not-null=The parameter 'currencies' is mandatory. org.apache.fineract.organisation.monetary.currencies.not-empty=The parameter 'currencies' cannot be empty. + +# Re-Age + +org.apache.fineract.reage.frequency-number.not-blank=The parameter 'frequencyNumber' is mandatory. +org.apache.fineract.reage.frequency-number.min=The parameter 'frequencyNumber' must be at least 1. +org.apache.fineract.reage.frequency-type.not-blank=The parameter 'frequencyType' is mandatory. +org.apache.fineract.reage.start-date.not-blank=The parameter 'startDate' is mandatory. +org.apache.fineract.reage.number-of-installments.not-blank=The parameter 'numberOfInstallments' is mandatory. +org.apache.fineract.reage.number-of-installments.min=The parameter 'numberOfInstallments' must be at least 1. +org.apache.fineract.frequency-type.invalid=The parameter 'frequencyType' must be valid PeriodFrequencyType value. Provided value: '${validatedValue}'.