FINERACT-2354: Introduce reaging preview API

This commit is contained in:
Oleksii Novikov
2025-11-11 12:04:51 +01:00
committed by Adam Saghy
parent ed8e9d1376
commit 3efafa778e
19 changed files with 1772 additions and 446 deletions
@@ -20,19 +20,27 @@ package org.apache.fineract.test.stepdef.loan;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.cucumber.datatable.DataTable; import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.Then; import io.cucumber.java.en.Then;
import io.cucumber.java.en.When; import io.cucumber.java.en.When;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import okhttp3.ResponseBody; 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.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansResponse; 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.ErrorHelper;
import org.apache.fineract.test.helper.ErrorMessageHelper; import org.apache.fineract.test.helper.ErrorMessageHelper;
import org.apache.fineract.test.helper.ErrorResponse; 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.EventAssertion;
import org.apache.fineract.test.messaging.event.loan.LoanReAgeEvent; import org.apache.fineract.test.messaging.event.loan.LoanReAgeEvent;
import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.stepdef.AbstractStepDef;
import org.apache.fineract.test.support.TestContextKey; import org.apache.fineract.test.support.TestContextKey;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.springframework.beans.factory.annotation.Autowired;
import retrofit2.Response; import retrofit2.Response;
@Slf4j @Slf4j
@RequiredArgsConstructor
public class LoanReAgingStepDef extends AbstractStepDef { public class LoanReAgingStepDef extends AbstractStepDef {
@Autowired private static final String DATE_FORMAT = "dd MMMM yyyy";
private LoanTransactionsApi loanTransactionsApi; private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT);
@Autowired private final LoanTransactionsApi loanTransactionsApi;
private EventAssertion eventAssertion; private final EventAssertion eventAssertion;
@When("Admin creates a Loan re-aging transaction with the following data:") @When("Admin creates a Loan re-aging transaction with the following data:")
public void createReAgingTransaction(DataTable table) throws IOException { 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(errorDetails.getHttpStatusCode()).as(ErrorMessageHelper.dateFailureErrorCodeMsg()).isEqualTo(403);
assertThat(developerMessage).matches(ErrorMessageHelper.reAgeContractTerminatedLoanFailure()); 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<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
long loanId = loanResponse.body().getLoanId();
List<String> 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<LoanScheduleData> 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<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
String loanExternalId = loanResponse.body().getResourceExternalId();
List<String> 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<LoanScheduleData> 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<LoanScheduleData> scheduleResponse = testContext().get(TestContextKey.LOAN_REAGING_PREVIEW_RESPONSE);
List<LoanSchedulePeriodData> repaymentPeriods = scheduleResponse.body().getPeriods();
Response<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
String resourceId = String.valueOf(loanResponse.body().getLoanId());
List<List<String>> 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<String> expectedValues = data.get(i);
String dueDateExpected = expectedValues.get(2);
List<List<String>> 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<List<String>> data = table.asLists();
List<String> header = data.get(0);
List<String> expectedValues = data.get(1);
Response<LoanScheduleData> scheduleResponse = testContext().get(TestContextKey.LOAN_REAGING_PREVIEW_RESPONSE);
validateRepaymentScheduleTotal(header, scheduleResponse.body(), expectedValues);
}
private List<String> fetchValuesOfRepaymentSchedule(List<String> header, LoanSchedulePeriodData repaymentPeriod) {
List<String> 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<String> validateRepaymentScheduleTotal(List<String> header, LoanScheduleData repaymentSchedule,
List<String> expectedAmounts) {
List<String> actualValues = new ArrayList<>();
Double paidActual = 0.0;
List<LoanSchedulePeriodData> 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;
}
} }
@@ -48,6 +48,7 @@ public abstract class TestContextKey {
public static final String LOAN_REFUND_RESPONSE = "loanRefundResponse"; public static final String LOAN_REFUND_RESPONSE = "loanRefundResponse";
public static final String LOAN_REAGING_RESPONSE = "loanReAgingResponse"; public static final String LOAN_REAGING_RESPONSE = "loanReAgingResponse";
public static final String LOAN_REAGING_UNDO_RESPONSE = "loanReAgingUndoResponse"; 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_RESPONSE = "loanReAmortizationResponse";
public static final String LOAN_REAMORTIZATION_UNDO_RESPONSE = "loanReAmortizationUndoResponse"; public static final String LOAN_REAMORTIZATION_UNDO_RESPONSE = "loanReAmortizationUndoResponse";
public static final String BUSINESS_DATE_RESPONSE = "businessDateResponse"; public static final String BUSINESS_DATE_RESPONSE = "businessDateResponse";
@@ -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 |
@@ -64,4 +64,12 @@ public interface LoanRepaymentScheduleInstallmentRepository
@Param("businessDate") LocalDate businessDate, @Param("obligationsMet") boolean obligationsMet, @Param("businessDate") LocalDate businessDate, @Param("obligationsMet") boolean obligationsMet,
@Param("loanIds") List<Long> loanIds); @Param("loanIds") List<Long> loanIds);
@Query("""
SELECT i
FROM LoanRepaymentScheduleInstallment i
WHERE i.loan.id = :loanId
ORDER BY i.loan.id, i.installmentNumber
""")
List<LoanRepaymentScheduleInstallment> findByLoanId(@Param("loanId") Long loanId);
} }
@@ -32,6 +32,8 @@ public interface ReprocessLoanTransactionsService {
void reprocessTransactionsWithPostTransactionChecks(Loan loan, LocalDate transactionDate); void reprocessTransactionsWithPostTransactionChecks(Loan loan, LocalDate transactionDate);
void reprocessTransactionsWithoutChecks(Loan loan, LocalDate transactionDate, List<LoanTransaction> newTransactions);
void processPostDisbursementTransactions(Loan loan); void processPostDisbursementTransactions(Loan loan);
void removeLoanCharge(Loan loan, LoanCharge loanCharge); void removeLoanCharge(Loan loan, LoanCharge loanCharge);
@@ -2868,7 +2868,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
target.setReAged(true); target.setReAged(true);
target.setFromDate(reAgedInstallment.getFromDate()); target.setFromDate(reAgedInstallment.getFromDate());
target.setDueDate(reAgedInstallment.getDueDate()); 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.setInterestCharged(MathUtil.add(reAgedInstallment.getInterestCharged(), target.getInterestPaid()));
target.updateObligationsMet(currency, transactionDate); target.updateObligationsMet(currency, transactionDate);
} }
@@ -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.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; 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.Consumes;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; 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.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; 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.LoanRepaymentScheduleInstallmentData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException; import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; 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.LoanChargePaidByReadService;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; 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.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadPlatformService; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadPlatformService;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -104,6 +109,7 @@ public class LoanTransactionsApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final PaymentTypeReadPlatformService paymentTypeReadPlatformService; private final PaymentTypeReadPlatformService paymentTypeReadPlatformService;
private final LoanChargePaidByReadService loanChargePaidByReadService; private final LoanChargePaidByReadService loanChargePaidByReadService;
private final LoanReAgingService loanReAgingService;
@GET @GET
@Path("{loanId}/transactions/template") @Path("{loanId}/transactions/template")
@@ -777,4 +783,26 @@ public class LoanTransactionsApiResource {
throw new IllegalArgumentException("loanId and loanExternalId cannot be both null"); 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);
}
} }
@@ -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;
}
@@ -24,7 +24,7 @@ import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.DataIntegrityErrorHandler; import org.apache.fineract.infrastructure.DataIntegrityErrorHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; 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.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -34,7 +34,7 @@ import org.springframework.stereotype.Service;
@CommandType(entity = "LOAN", action = "REAGE") @CommandType(entity = "LOAN", action = "REAGE")
public class LoanReAgingCommandHandler implements NewCommandSourceHandler { public class LoanReAgingCommandHandler implements NewCommandSourceHandler {
private final LoanReAgingServiceImpl loanReAgingService; private final LoanReAgingService loanReAgingService;
private final DataIntegrityErrorHandler dataIntegrityErrorHandler; private final DataIntegrityErrorHandler dataIntegrityErrorHandler;
@Override @Override
@@ -24,7 +24,7 @@ import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.DataIntegrityErrorHandler; import org.apache.fineract.infrastructure.DataIntegrityErrorHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; 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.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -34,7 +34,7 @@ import org.springframework.stereotype.Service;
@CommandType(entity = "LOAN", action = "UNDO_REAGE") @CommandType(entity = "LOAN", action = "UNDO_REAGE")
public class LoanUndoReAgingCommandHandler implements NewCommandSourceHandler { public class LoanUndoReAgingCommandHandler implements NewCommandSourceHandler {
private final LoanReAgingServiceImpl loanReAgingService; private final LoanReAgingService loanReAgingService;
private final DataIntegrityErrorHandler dataIntegrityErrorHandler; private final DataIntegrityErrorHandler dataIntegrityErrorHandler;
@Override @Override
@@ -31,8 +31,6 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; 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.LoanChargePaidByData;
import org.apache.fineract.portfolio.loanaccount.data.LoanInterestRecalculationData; import org.apache.fineract.portfolio.loanaccount.data.LoanInterestRecalculationData;
import org.apache.fineract.portfolio.loanaccount.data.LoanRepaymentScheduleInstallmentData; 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.LoanStatusEnumData;
import org.apache.fineract.portfolio.loanaccount.data.LoanSummaryData; import org.apache.fineract.portfolio.loanaccount.data.LoanSummaryData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData; 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.LoanNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; 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.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.data.OverdueLoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; 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.dao.EmptyResultDataAccessException;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.lang.NonNull;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
@@ -188,7 +182,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
private final ConfigurationDomainService configurationDomainService; private final ConfigurationDomainService configurationDomainService;
private final AccountDetailsReadPlatformService accountDetailsReadPlatformService; private final AccountDetailsReadPlatformService accountDetailsReadPlatformService;
private final ColumnValidator columnValidator; private final ColumnValidator columnValidator;
protected final DatabaseSpecificSQLGenerator sqlGenerator; private final DatabaseSpecificSQLGenerator sqlGenerator;
private final DelinquencyReadPlatformService delinquencyReadPlatformService; private final DelinquencyReadPlatformService delinquencyReadPlatformService;
private final LoanTransactionRepository loanTransactionRepository; private final LoanTransactionRepository loanTransactionRepository;
private final LoanChargePaidByReadService loanChargePaidByReadService; private final LoanChargePaidByReadService loanChargePaidByReadService;
@@ -201,6 +195,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
private final LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository; private final LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository;
private final InterestRefundServiceDelegate interestRefundServiceDelegate; private final InterestRefundServiceDelegate interestRefundServiceDelegate;
private final LoanMaximumAmountCalculator loanMaximumAmountCalculator; private final LoanMaximumAmountCalculator loanMaximumAmountCalculator;
private final LoanRepaymentScheduleService loanRepaymentScheduleService;
@Override @Override
public LoanAccountData retrieveOne(final Long loanId) { public LoanAccountData retrieveOne(final Long loanId) {
@@ -280,19 +275,9 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection<DisbursementData> disbursementData, final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection<DisbursementData> disbursementData,
Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData, boolean isInterestRecalculationEnabled, Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData, boolean isInterestRecalculationEnabled,
LoanScheduleType loanScheduleType) { LoanScheduleType loanScheduleType) {
this.context.authenticatedUser();
try { return loanRepaymentScheduleService.findLoanScheduleData(loanId, repaymentScheduleRelatedLoanData, disbursementData,
this.context.authenticatedUser(); capitalizedIncomeData, isInterestRecalculationEnabled, loanScheduleType);
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);
}
} }
@Override @Override
@@ -1339,373 +1324,6 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
} }
} }
private static final class LoanScheduleResultSetExtractor implements ResultSetExtractor<LoanScheduleData> {
private final CurrencyData currency;
private final DisbursementData disbursement;
private final BigDecimal totalFeeChargesDueAtDisbursement;
private final Collection<DisbursementData> disbursementData;
private final Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData;
private final LoanScheduleType loanScheduleType;
private LocalDate lastDueDate;
private BigDecimal outstandingLoanPrincipalBalance;
private boolean excludePastUnDisbursed;
LoanScheduleResultSetExtractor(final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData,
Collection<DisbursementData> disbursementData, Collection<LoanTransactionRepaymentPeriodData> 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<LoanSchedulePeriodData> 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<Long> 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<LoanSchedulePeriodDataWrapper> 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<LoanSchedulePeriodDataWrapper> collectEligibleDisbursementData(LoanScheduleType loanScheduleType,
Collection<DisbursementData> disbursementData, LocalDate fromDate, LocalDate dueDate, Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> disbursementDataList = new ArrayList<>();
boolean hasMultipleTranchesOnSameDate = hasMultipleTranchesOnSameDate(disbursementData);
if (hasMultipleTranchesOnSameDate) {
Map<LocalDate, List<DisbursementData>> 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<LocalDate, List<DisbursementData>> entry : disbursementsByDate.entrySet()) {
List<DisbursementData> sameDateDisbursements = entry.getValue();
if (sameDateDisbursements.size() > 1) {
List<DisbursementData> 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> 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<LoanSchedulePeriodDataWrapper> collectEligibleCapitalizedIncomeData(LocalDate fromDate, LocalDate dueDate,
Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> 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<LoanSchedulePeriodData> periods, List<LoanSchedulePeriodDataWrapper> 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<LoanSchedulePeriodDataWrapper> 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<LoanTransactionData> { protected static class LoanTransactionsMapper implements RowMapper<LoanTransactionData> {
private final DatabaseSpecificSQLGenerator sqlGenerator; private final DatabaseSpecificSQLGenerator sqlGenerator;
@@ -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> disbursementData, Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData,
boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) {
final List<LoanRepaymentScheduleInstallment> installments = this.loanRepaymentScheduleInstallmentRepository.findByLoanId(loanId);
return extractLoanScheduleData(installments, repaymentScheduleRelatedLoanData, disbursementData, capitalizedIncomeData,
isInterestRecalculationEnabled, loanScheduleType);
}
public LoanScheduleData extractLoanScheduleData(final List<LoanRepaymentScheduleInstallment> installments,
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection<DisbursementData> disbursementData,
Collection<LoanTransactionRepaymentPeriodData> 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<LoanSchedulePeriodData> 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<Long> 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<LoanSchedulePeriodDataWrapper> 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<LoanSchedulePeriodDataWrapper> collectEligibleDisbursementData(LoanScheduleType loanScheduleType,
Collection<DisbursementData> disbursementData, LocalDate fromDate, LocalDate dueDate, Set<Long> disbursementPeriodIds,
DisbursementData mainDisbursement, boolean excludePastUnDisbursed) {
List<LoanSchedulePeriodDataWrapper> disbursementDataList = new ArrayList<>();
boolean hasMultipleTranchesOnSameDate = hasMultipleTranchesOnSameDate(disbursementData);
if (hasMultipleTranchesOnSameDate) {
Map<LocalDate, List<DisbursementData>> 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<LocalDate, List<DisbursementData>> entry : disbursementsByDate.entrySet()) {
List<DisbursementData> sameDateDisbursements = entry.getValue();
if (sameDateDisbursements.size() > 1) {
List<DisbursementData> 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> 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<LoanSchedulePeriodDataWrapper> collectEligibleCapitalizedIncomeData(
Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData, LocalDate fromDate, LocalDate dueDate,
Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> 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<LoanSchedulePeriodData> periods,
List<LoanSchedulePeriodDataWrapper> 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<LoanSchedulePeriodDataWrapper> 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());
}
}
@@ -90,6 +90,14 @@ public class ReprocessLoanTransactionsServiceImpl implements ReprocessLoanTransa
handleChangedDetail(changedTransactionDetail); handleChangedDetail(changedTransactionDetail);
} }
@Override
public void reprocessTransactionsWithoutChecks(final Loan loan, final LocalDate transactionDate,
final List<LoanTransaction> newTransactions) {
final List<LoanTransaction> transactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan);
transactions.addAll(newTransactions);
reprocessTransactionsAndFetchChangedTransactions(loan, transactions);
}
@Override @Override
public void processPostDisbursementTransactions(final Loan loan) { public void processPostDisbursementTransactions(final Loan loan) {
loanTransactionProcessingService.processPostDisbursementTransactions(loan).ifPresent(this::handleChangedDetail); loanTransactionProcessingService.processPostDisbursementTransactions(loan).ifPresent(this::handleChangedDetail);
@@ -22,10 +22,13 @@ import static java.math.BigDecimal.ZERO;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; 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.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId; 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.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.event.business.domain.loan.reaging.LoanReAgeBusinessEvent; 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.LoanReAgeTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.reaging.LoanUndoReAgeTransactionBusinessEvent; 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.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.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants; import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants; 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.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan; 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.LoanRepaymentScheduleTransactionProcessorFactory;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; 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.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeInterestHandlingType; 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.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; 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.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; 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.LoanScheduleService;
import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService; import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService;
import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService; import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService;
@@ -69,7 +83,7 @@ import org.springframework.transaction.annotation.Transactional;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional @Transactional
public class LoanReAgingServiceImpl { public class LoanReAgingService {
private final LoanAssembler loanAssembler; private final LoanAssembler loanAssembler;
private final LoanReAgingValidator reAgingValidator; private final LoanReAgingValidator reAgingValidator;
@@ -83,40 +97,22 @@ public class LoanReAgingServiceImpl {
private final LoanScheduleService loanScheduleService; private final LoanScheduleService loanScheduleService;
private final ReprocessLoanTransactionsService reprocessLoanTransactionsService; private final ReprocessLoanTransactionsService reprocessLoanTransactionsService;
private final CodeValueRepository codeValueRepository; private final CodeValueRepository codeValueRepository;
private final LoanRepaymentScheduleService loanRepaymentScheduleService;
private final LoanReadPlatformService loanReadPlatformService;
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
public CommandProcessingResult reAge(Long loanId, JsonCommand command) { public CommandProcessingResult reAge(final Long loanId, final JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId); final Loan loan = loanAssembler.assembleFrom(loanId);
reAgingValidator.validateReAge(loan, command); reAgingValidator.validateReAge(loan, command);
Map<String, Object> changes = new LinkedHashMap<>(); final LoanTransaction reAgeTransaction = createReAgeTransaction(loan, command);
loanTransactionRepository.saveAndFlush(reAgeTransaction);
processReAgeTransaction(loan, reAgeTransaction, true);
loan.updateLoanScheduleDependentDerivedFields();
final Map<String, Object> changes = new LinkedHashMap<>();
changes.put(LoanReAgingApiConstants.localeParameterName, command.locale()); changes.put(LoanReAgingApiConstants.localeParameterName, command.locale());
changes.put(LoanReAgingApiConstants.dateFormatParameterName, command.dateFormat()); 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<LoanTransaction> 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); persistNote(loan, command, changes);
// delinquency recalculation will be triggered by the event in a decoupled way via a listener // 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(); .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> disbursementData = loanReadPlatformService.retrieveLoanDisbursementDetails(loan.getId());
final Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData = loanCapitalizedIncomeBalanceRepository
.findRepaymentPeriodDataByLoanId(loan.getId());
final List<LoanRepaymentScheduleInstallment> 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) { public CommandProcessingResult undoReAge(Long loanId, JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId); Loan loan = loanAssembler.assembleFrom(loanId);
reAgingValidator.validateUndoReAge(loan, command); reAgingValidator.validateUndoReAge(loan);
Map<String, Object> changes = new LinkedHashMap<>(); Map<String, Object> changes = new LinkedHashMap<>();
changes.put(LoanReAgingApiConstants.localeParameterName, command.locale()); changes.put(LoanReAgingApiConstants.localeParameterName, command.locale());
@@ -170,6 +195,35 @@ public class LoanReAgingServiceImpl {
.with(changes).build(); .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<LoanTransaction> 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) { private void reverseReAgeTransaction(LoanTransaction reAgeTransaction, JsonCommand command) {
ExternalId reversalExternalId = externalIdFactory.createFromCommand(command, LoanReAgingApiConstants.externalIdParameterName); ExternalId reversalExternalId = externalIdFactory.createFromCommand(command, LoanReAgingApiConstants.externalIdParameterName);
loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(reAgeTransaction.getLoan(), reAgeTransaction, loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(reAgeTransaction.getLoan(), reAgeTransaction,
@@ -199,8 +253,13 @@ public class LoanReAgingServiceImpl {
Money txPrincipal = loan.getTotalPrincipalOutstandingUntil(transactionDate); Money txPrincipal = loan.getTotalPrincipalOutstandingUntil(transactionDate);
BigDecimal txPrincipalAmount = txPrincipal.getAmount(); BigDecimal txPrincipalAmount = txPrincipal.getAmount();
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate, txPrincipalAmount, txPrincipalAmount, final LoanTransaction reAgeTransaction = new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate,
ZERO, ZERO, ZERO, null, false, null, txExternalId); 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) { private LoanReAgeParameter createReAgeParameter(LoanTransaction reAgeTransaction, JsonCommand command) {
@@ -235,4 +294,43 @@ public class LoanReAgingServiceImpl {
this.noteRepository.saveAndFlush(newNote); 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);
}
} }
@@ -24,6 +24,7 @@ import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.codes.domain.CodeValue; 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.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; 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.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants; 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.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
@@ -57,6 +60,12 @@ public class LoanReAgingValidator {
validateReAgeOutstandingBalance(loan, command); 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) { private void validateReAgeRequest(Loan loan, JsonCommand command) {
List<ApiParameterError> dataValidationErrors = new ArrayList<>(); List<ApiParameterError> dataValidationErrors = new ArrayList<>();
DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.reAge"); 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); validateUndoReAgeBusinessRules(loan);
} }
@@ -181,4 +190,42 @@ public class LoanReAgingValidator {
} }
} }
private void validateReAgeRequest(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) {
final List<ApiParameterError> 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());
}
}
} }
@@ -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.LoanReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.LoanRefundService; 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.LoanScheduleService;
import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformServiceImpl;
@@ -350,7 +351,8 @@ public class LoanAccountConfiguration {
LoanTransactionProcessingService loanTransactionProcessingService, LoanBalanceService loanBalanceService, LoanTransactionProcessingService loanTransactionProcessingService, LoanBalanceService loanBalanceService,
LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository, LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository,
LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository, LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository,
@Lazy InterestRefundServiceDelegate interestRefundServiceDelegate, LoanMaximumAmountCalculator loanMaximumAmountCalculator) { @Lazy InterestRefundServiceDelegate interestRefundServiceDelegate, LoanMaximumAmountCalculator loanMaximumAmountCalculator,
LoanRepaymentScheduleService loanRepaymentScheduleService) {
return new LoanReadPlatformServiceImpl(jdbcTemplate, context, loanRepositoryWrapper, applicationCurrencyRepository, return new LoanReadPlatformServiceImpl(jdbcTemplate, context, loanRepositoryWrapper, applicationCurrencyRepository,
loanProductReadPlatformService, clientReadPlatformService, groupReadPlatformService, loanDropdownReadPlatformService, loanProductReadPlatformService, clientReadPlatformService, groupReadPlatformService, loanDropdownReadPlatformService,
fundReadPlatformService, chargeReadPlatformService, codeValueReadPlatformService, calendarReadPlatformService, fundReadPlatformService, chargeReadPlatformService, codeValueReadPlatformService, calendarReadPlatformService,
@@ -359,7 +361,7 @@ public class LoanAccountConfiguration {
delinquencyReadPlatformService, loanTransactionRepository, loanChargePaidByReadService, loanTransactionRelationReadService, delinquencyReadPlatformService, loanTransactionRepository, loanChargePaidByReadService, loanTransactionRelationReadService,
loanForeclosureValidator, loanTransactionMapper, loanTransactionProcessingService, loanBalanceService, loanForeclosureValidator, loanTransactionMapper, loanTransactionProcessingService, loanBalanceService,
loanCapitalizedIncomeBalanceRepository, loanBuyDownFeeBalanceRepository, interestRefundServiceDelegate, loanCapitalizedIncomeBalanceRepository, loanBuyDownFeeBalanceRepository, interestRefundServiceDelegate,
loanMaximumAmountCalculator); loanMaximumAmountCalculator, loanRepaymentScheduleService);
} }
@Bean @Bean
@@ -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();
}
}
@@ -113,7 +113,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.externalId.exceeds.max.length");
} }
@@ -137,7 +137,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.startDate.cannot.be.blank");
} }
@@ -161,7 +161,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.frequencyType.cannot.be.blank");
} }
@@ -185,7 +185,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.frequencyNumber.cannot.be.blank");
} }
@@ -210,7 +210,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.frequencyNumber.not.greater.than.zero");
} }
@@ -234,7 +234,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.numberOfInstallments.cannot.be.blank");
} }
@@ -259,7 +259,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.numberOfInstallments.not.greater.than.zero");
} }
@@ -284,7 +284,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.numberOfInstallments.not.greater.than.zero");
} }
@@ -301,7 +301,7 @@ class LoanReAgingValidatorTest {
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); 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"); .isEqualTo("validation.msg.loan.reAge.startDate.is.less.than.date");
} }
@@ -371,10 +371,9 @@ class LoanReAgingValidatorTest {
List<LoanTransaction> transactions = List.of(loanTransaction(LoanTransactionType.DISBURSEMENT, actualDate.minusDays(3))); List<LoanTransaction> transactions = List.of(loanTransaction(LoanTransactionType.DISBURSEMENT, actualDate.minusDays(3)));
Loan loan = loan(); Loan loan = loan();
given(loan.getLoanTransactions()).willReturn(transactions); given(loan.getLoanTransactions()).willReturn(transactions);
JsonCommand command = jsonCommand();
// when // when
GeneralPlatformDomainRuleException result = assertThrows(GeneralPlatformDomainRuleException.class, GeneralPlatformDomainRuleException result = assertThrows(GeneralPlatformDomainRuleException.class,
() -> underTest.validateUndoReAge(loan, command)); () -> underTest.validateUndoReAge(loan));
// then // then
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("error.msg.loan.reage.reaging.transaction.missing"); 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))); OffsetDateTime.of(actualDate, LocalTime.of(9, 0), ZoneOffset.UTC)));
Loan loan = loan(); Loan loan = loan();
given(loan.getLoanTransactions()).willReturn(transactions); given(loan.getLoanTransactions()).willReturn(transactions);
JsonCommand command = jsonCommand();
// when // when
underTest.validateUndoReAge(loan, command); underTest.validateUndoReAge(loan);
// then no exception thrown // then no exception thrown
} }
@@ -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-null=The parameter 'currencies' is mandatory.
org.apache.fineract.organisation.monetary.currencies.not-empty=The parameter 'currencies' cannot be empty. 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}'.