Skip to content

Commit b867efd

Browse files
committed
FINERACT-1152: Fix loan reschedule EMI end-date handling
1 parent 7597eed commit b867efd

5 files changed

Lines changed: 239 additions & 37 deletions

File tree

fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/rescheduleloan/api/RescheduleLoansApiResourceSwagger.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ public static final class PostCreateRescheduleLoansRequest {
194194
public BigDecimal newInterestRate;
195195
@Schema(example = "20 September 2011")
196196
public String rescheduleFromDate;
197+
@Schema(example = "20 September 2011")
198+
public String endDate;
199+
@Schema(example = "100.0")
200+
public BigDecimal emi;
197201
@Schema(example = "comment")
198202
public String rescheduleReasonComment;
199203
@Schema(example = "1")

fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/rescheduleloan/data/LoanRescheduleRequestDataValidatorImpl.java

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,21 +98,17 @@ private static void validateMultiDisburseLoan(Loan loan, DataValidatorBuilder da
9898
}
9999
}
100100

101-
public static void validateEMIAndEndDate(FromJsonHelper fromJsonHelper, Loan loan, JsonElement jsonElement,
101+
public static void validateEMIAndEndDate(FromJsonHelper fromJsonHelper, JsonElement jsonElement, LocalDate rescheduleFromDate,
102102
DataValidatorBuilder dataValidatorBuilder) {
103103
final LocalDate endDate = fromJsonHelper.extractLocalDateNamed(RescheduleLoansApiConstants.endDateParamName, jsonElement);
104104
final BigDecimal emi = fromJsonHelper.extractBigDecimalWithLocaleNamed(RescheduleLoansApiConstants.emiParamName, jsonElement);
105105
if (emi != null || endDate != null) {
106106
dataValidatorBuilder.reset().parameter(RescheduleLoansApiConstants.endDateParamName).value(endDate).notNull();
107107
dataValidatorBuilder.reset().parameter(RescheduleLoansApiConstants.emiParamName).value(emi).notNull().positiveAmount();
108108

109-
if (endDate != null) {
110-
LoanRepaymentScheduleInstallment endInstallment = loan.fetchLoanRepaymentScheduleInstallmentByDueDate(endDate);
111-
112-
if (endInstallment == null) {
113-
dataValidatorBuilder.reset().parameter(RescheduleLoansApiConstants.endDateParamName)
114-
.failWithCode("repayment.schedule.installment.does.not.exist", "Repayment schedule installment does not exist");
115-
}
109+
if (endDate != null && DateUtils.isBefore(endDate, rescheduleFromDate)) {
110+
dataValidatorBuilder.reset().parameter(RescheduleLoansApiConstants.endDateParamName)
111+
.failWithCode("end.date.before.reschedule.from.date", "End date cannot be before the reschedule from date");
116112
}
117113
}
118114
}
@@ -272,7 +268,7 @@ public void validateForCreateAction(final JsonCommand jsonCommand, final Loan lo
272268
validateRescheduleReasonId(fromJsonHelper, jsonElement, dataValidatorBuilder);
273269
validateRescheduleReasonComment(fromJsonHelper, jsonElement, dataValidatorBuilder);
274270
validateAndRetrieveAdjustedDate(fromJsonHelper, jsonElement, rescheduleFromDate, dataValidatorBuilder);
275-
validateEMIAndEndDate(fromJsonHelper, loan, jsonElement, dataValidatorBuilder);
271+
validateEMIAndEndDate(fromJsonHelper, jsonElement, rescheduleFromDate, dataValidatorBuilder);
276272
validateIsThereAnyIncomingChange(fromJsonHelper, jsonElement, dataValidatorBuilder);
277273
validateMultiDisburseLoan(loan, dataValidatorBuilder);
278274

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.loanaccount.rescheduleloan.data;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertTrue;
23+
import static org.mockito.Mockito.mock;
24+
import static org.mockito.Mockito.when;
25+
26+
import com.google.gson.JsonElement;
27+
import java.math.BigDecimal;
28+
import java.time.LocalDate;
29+
import java.util.ArrayList;
30+
import java.util.List;
31+
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
32+
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
33+
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
34+
import org.apache.fineract.portfolio.loanaccount.rescheduleloan.RescheduleLoansApiConstants;
35+
import org.junit.jupiter.api.Test;
36+
37+
class LoanRescheduleRequestDataValidatorImplTest {
38+
39+
@Test
40+
void validateEMIAndEndDateShouldFailWhenEndDateBeforeRescheduleFromDate() {
41+
FromJsonHelper fromJsonHelper = mock(FromJsonHelper.class);
42+
JsonElement jsonElement = mock(JsonElement.class);
43+
LocalDate rescheduleFromDate = LocalDate.of(2026, 3, 10);
44+
LocalDate endDate = LocalDate.of(2026, 3, 9);
45+
46+
when(fromJsonHelper.extractLocalDateNamed(RescheduleLoansApiConstants.endDateParamName, jsonElement)).thenReturn(endDate);
47+
when(fromJsonHelper.extractBigDecimalWithLocaleNamed(RescheduleLoansApiConstants.emiParamName, jsonElement))
48+
.thenReturn(BigDecimal.valueOf(100));
49+
50+
List<ApiParameterError> errors = new ArrayList<>();
51+
DataValidatorBuilder dataValidatorBuilder = new DataValidatorBuilder(errors).resource("loanreschedule");
52+
53+
LoanRescheduleRequestDataValidatorImpl.validateEMIAndEndDate(fromJsonHelper, jsonElement, rescheduleFromDate, dataValidatorBuilder);
54+
55+
assertEquals(1, errors.size());
56+
assertEquals(RescheduleLoansApiConstants.endDateParamName, errors.getFirst().getParameterName());
57+
assertTrue(errors.getFirst().getUserMessageGlobalisationCode().contains("end.date.before.reschedule.from.date"));
58+
}
59+
60+
@Test
61+
void validateEMIAndEndDateShouldAllowNonInstallmentEndDate() {
62+
FromJsonHelper fromJsonHelper = mock(FromJsonHelper.class);
63+
JsonElement jsonElement = mock(JsonElement.class);
64+
LocalDate rescheduleFromDate = LocalDate.of(2026, 3, 10);
65+
LocalDate endDate = LocalDate.of(2026, 4, 13);
66+
67+
when(fromJsonHelper.extractLocalDateNamed(RescheduleLoansApiConstants.endDateParamName, jsonElement)).thenReturn(endDate);
68+
when(fromJsonHelper.extractBigDecimalWithLocaleNamed(RescheduleLoansApiConstants.emiParamName, jsonElement))
69+
.thenReturn(BigDecimal.valueOf(100));
70+
71+
List<ApiParameterError> errors = new ArrayList<>();
72+
DataValidatorBuilder dataValidatorBuilder = new DataValidatorBuilder(errors).resource("loanreschedule");
73+
74+
LoanRescheduleRequestDataValidatorImpl.validateEMIAndEndDate(fromJsonHelper, jsonElement, rescheduleFromDate, dataValidatorBuilder);
75+
76+
assertTrue(errors.isEmpty());
77+
}
78+
}

fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/rescheduleloan/service/LoanRescheduleRequestWritePlatformServiceImpl.java

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.util.Map;
3131
import java.util.Optional;
3232
import java.util.Set;
33+
import java.util.TreeSet;
3334
import lombok.RequiredArgsConstructor;
3435
import lombok.extern.slf4j.Slf4j;
3536
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
@@ -68,6 +69,7 @@
6869
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanRepaymentScheduleHistoryRepository;
6970
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleGenerator;
7071
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleGeneratorFactory;
72+
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
7173
import org.apache.fineract.portfolio.loanaccount.loanschedule.service.LoanScheduleHistoryWritePlatformService;
7274
import org.apache.fineract.portfolio.loanaccount.mapper.LoanTermVariationsMapper;
7375
import org.apache.fineract.portfolio.loanaccount.rescheduleloan.RescheduleLoansApiConstants;
@@ -248,34 +250,22 @@ private void createLoanTermVariationsForRegularLoans(final Loan loan, final Inte
248250
List<LoanRescheduleRequestToTermVariationMapping> loanRescheduleRequestToTermVariationMappings, final Boolean isActive,
249251
final boolean isSpecificToInstallment, BigDecimal decimalValue, LocalDate dueDate, LocalDate endDate, BigDecimal emi) {
250252

251-
if (rescheduleFromDate != null && endDate != null && emi != null) {
252-
LoanTermVariations parent = null;
253-
final Integer termType = LoanTermVariationType.EMI_AMOUNT.getValue();
254-
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
255-
for (LoanRepaymentScheduleInstallment installment : installments) {
256-
if (!DateUtils.isBefore(installment.getDueDate(), rescheduleFromDate)
257-
&& !DateUtils.isAfter(installment.getDueDate(), endDate)) {
258-
createLoanTermVariations(loanRescheduleRequest, termType, loan, installment.getDueDate(), installment.getDueDate(),
259-
loanRescheduleRequestToTermVariationMappings, isActive, true, emi, parent);
260-
}
261-
if (DateUtils.isAfter(installment.getDueDate(), endDate)) {
262-
break;
263-
}
264-
}
265-
}
253+
List<LoanTermVariations> pendingRescheduleVariations = new ArrayList<>();
266254

267255
if (rescheduleFromDate != null && adjustedDueDate != null) {
268256
LoanTermVariations parent = null;
269257
final Integer termType = LoanTermVariationType.DUE_DATE.getValue();
270-
createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, adjustedDueDate,
271-
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, decimalValue, parent);
258+
LoanTermVariations loanTermVariation = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate,
259+
adjustedDueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, decimalValue, parent);
260+
pendingRescheduleVariations.add(loanTermVariation);
272261
}
273262

274263
if (rescheduleFromDate != null && interestRate != null) {
275264
LoanTermVariations parent = null;
276265
final Integer termType = LoanTermVariationType.INTEREST_RATE_FROM_INSTALLMENT.getValue();
277-
createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, dueDate,
278-
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, interestRate, parent);
266+
LoanTermVariations loanTermVariation = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate,
267+
dueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, interestRate, parent);
268+
pendingRescheduleVariations.add(loanTermVariation);
279269
}
280270

281271
if (rescheduleFromDate != null && graceOnPrincipal != null) {
@@ -284,32 +274,111 @@ private void createLoanTermVariationsForRegularLoans(final Loan loan, final Inte
284274
parent = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, dueDate,
285275
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, BigDecimal.valueOf(graceOnPrincipal),
286276
parent);
277+
pendingRescheduleVariations.add(parent);
287278

288279
BigDecimal extraTermsBasedOnGracePeriods = BigDecimal.valueOf(graceOnPrincipal);
289-
createLoanTermVariations(loanRescheduleRequest, LoanTermVariationType.EXTEND_REPAYMENT_PERIOD.getValue(), loan,
290-
rescheduleFromDate, dueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment,
291-
extraTermsBasedOnGracePeriods, parent);
280+
LoanTermVariations extraTermsVariation = createLoanTermVariations(loanRescheduleRequest,
281+
LoanTermVariationType.EXTEND_REPAYMENT_PERIOD.getValue(), loan, rescheduleFromDate, dueDate,
282+
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, extraTermsBasedOnGracePeriods, parent);
283+
pendingRescheduleVariations.add(extraTermsVariation);
292284

293285
}
294286

295287
if (rescheduleFromDate != null && graceOnInterest != null) {
296288
LoanTermVariations parent = null;
297289
final Integer termType = LoanTermVariationType.GRACE_ON_INTEREST.getValue();
298-
createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, dueDate,
299-
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, BigDecimal.valueOf(graceOnInterest),
300-
parent);
290+
LoanTermVariations loanTermVariation = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate,
291+
dueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment,
292+
BigDecimal.valueOf(graceOnInterest), parent);
293+
pendingRescheduleVariations.add(loanTermVariation);
301294
}
302295

303296
if (rescheduleFromDate != null && extraTerms != null) {
304297
LoanTermVariations parent = null;
305298
final Integer termType = LoanTermVariationType.EXTEND_REPAYMENT_PERIOD.getValue();
306-
createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, dueDate,
307-
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, BigDecimal.valueOf(extraTerms),
308-
parent);
299+
LoanTermVariations loanTermVariation = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate,
300+
dueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment,
301+
BigDecimal.valueOf(extraTerms), parent);
302+
pendingRescheduleVariations.add(loanTermVariation);
303+
}
304+
305+
if (rescheduleFromDate != null && endDate != null && emi != null) {
306+
LoanTermVariations parent = null;
307+
final Integer termType = LoanTermVariationType.EMI_AMOUNT.getValue();
308+
int emiVariationsCreated = 0;
309+
List<LocalDate> projectedInstallmentDueDates = getProjectedInstallmentDueDates(loan, rescheduleFromDate,
310+
pendingRescheduleVariations);
311+
for (LocalDate installmentDueDate : projectedInstallmentDueDates) {
312+
if (!DateUtils.isBefore(installmentDueDate, rescheduleFromDate) && !DateUtils.isAfter(installmentDueDate, endDate)) {
313+
createLoanTermVariations(loanRescheduleRequest, termType, loan, installmentDueDate, installmentDueDate,
314+
loanRescheduleRequestToTermVariationMappings, isActive, true, emi, parent);
315+
emiVariationsCreated++;
316+
}
317+
if (DateUtils.isAfter(installmentDueDate, endDate)) {
318+
break;
319+
}
320+
}
321+
if (emiVariationsCreated == 0) {
322+
List<ApiParameterError> dataValidationErrors = new ArrayList<>();
323+
final DataValidatorBuilder dataValidatorBuilder = new DataValidatorBuilder(dataValidationErrors)
324+
.resource(RescheduleLoansApiConstants.ENTITY_NAME);
325+
dataValidatorBuilder.reset().parameter(RescheduleLoansApiConstants.endDateParamName).failWithCode(
326+
"end.date.before.next.installment", "End date must be on or after the next projected installment date");
327+
throw new PlatformApiDataValidationException(dataValidationErrors);
328+
}
309329
}
310330
loanRescheduleRequest.updateLoanRescheduleRequestToTermVariationMappings(loanRescheduleRequestToTermVariationMappings);
311331
}
312332

333+
private List<LocalDate> getProjectedInstallmentDueDates(final Loan loan, final LocalDate rescheduleFromDate,
334+
final List<LoanTermVariations> pendingRescheduleVariations) {
335+
ScheduleGeneratorDTO scheduleGeneratorDTO = this.loanUtilService.buildScheduleGeneratorDTO(loan, rescheduleFromDate);
336+
final LoanApplicationTerms loanApplicationTerms = loanTermVariationsMapper.constructLoanApplicationTerms(scheduleGeneratorDTO,
337+
loan);
338+
List<LoanTermVariationsData> projectedVariations = buildProjectedLoanTermVariatons(loanApplicationTerms,
339+
pendingRescheduleVariations);
340+
loanApplicationTerms.getLoanTermVariations().setExceptionData(projectedVariations);
341+
342+
final MathContext mathContext = MoneyHelper.getMathContext();
343+
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = this.loanRepaymentScheduleTransactionProcessorFactory
344+
.determineProcessor(loan.transactionProcessingStrategy());
345+
final LoanScheduleGenerator loanScheduleGenerator = this.loanScheduleFactory.create(loanApplicationTerms.getLoanScheduleType(),
346+
loanApplicationTerms.getInterestMethod());
347+
final LoanScheduleDTO projectedLoanSchedule = loanScheduleGenerator.rescheduleNextInstallments(mathContext, loanApplicationTerms,
348+
loan, loanApplicationTerms.getHolidayDetailDTO(), loanRepaymentScheduleTransactionProcessor, rescheduleFromDate);
349+
350+
Set<LocalDate> dueDates = new TreeSet<>();
351+
if (projectedLoanSchedule.getInstallments() != null) {
352+
for (LoanRepaymentScheduleInstallment installment : projectedLoanSchedule.getInstallments()) {
353+
dueDates.add(installment.getDueDate());
354+
}
355+
} else {
356+
for (LoanScheduleModelPeriod period : projectedLoanSchedule.getLoanScheduleModel().getPeriods()) {
357+
if (period.isRepaymentPeriod() || period.isDownPaymentPeriod()) {
358+
dueDates.add(period.periodDueDate());
359+
}
360+
}
361+
}
362+
return new ArrayList<>(dueDates);
363+
}
364+
365+
private List<LoanTermVariationsData> buildProjectedLoanTermVariatons(final LoanApplicationTerms loanApplicationTerms,
366+
final List<LoanTermVariations> pendingRescheduleVariations) {
367+
final List<LoanTermVariationsData> projectedVariations = new ArrayList<>();
368+
369+
projectedVariations.addAll(loanApplicationTerms.getLoanTermVariations().getExceptionData());
370+
projectedVariations.addAll(loanApplicationTerms.getLoanTermVariations().getDueDateVariation());
371+
projectedVariations.addAll(loanApplicationTerms.getLoanTermVariations().getInterestRateChanges());
372+
projectedVariations.addAll(loanApplicationTerms.getLoanTermVariations().getInterestRateFromInstallment());
373+
projectedVariations.addAll(loanApplicationTerms.getLoanTermVariations().getInterestPauseVariations());
374+
375+
for (LoanTermVariations pendingVariation : pendingRescheduleVariations) {
376+
projectedVariations.add(pendingVariation.toData());
377+
}
378+
379+
return projectedVariations;
380+
}
381+
313382
private LoanTermVariations createLoanTermVariations(LoanRescheduleRequest loanRescheduleRequest, final Integer termType,
314383
final Loan loan, LocalDate rescheduleFromDate, LocalDate adjustedDueDate,
315384
List<LoanRescheduleRequestToTermVariationMapping> loanRescheduleRequestToTermVariationMappings, final Boolean isActive,

0 commit comments

Comments
 (0)