Skip to content

Commit fa18ebd

Browse files
committed
FINERACT-1152: Fix loan reschedule EMI end-date handling
1 parent 7f3d40d commit fa18ebd

File tree

5 files changed

+239
-37
lines changed

5 files changed

+239
-37
lines changed

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;
@@ -253,34 +255,22 @@ private void createLoanTermVariationsForRegularLoans(final Loan loan, final Inte
253255
List<LoanRescheduleRequestToTermVariationMapping> loanRescheduleRequestToTermVariationMappings, final Boolean isActive,
254256
final boolean isSpecificToInstallment, BigDecimal decimalValue, LocalDate dueDate, LocalDate endDate, BigDecimal emi) {
255257

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

272260
if (rescheduleFromDate != null && adjustedDueDate != null) {
273261
LoanTermVariations parent = null;
274262
final Integer termType = LoanTermVariationType.DUE_DATE.getValue();
275-
createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, adjustedDueDate,
276-
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, decimalValue, parent);
263+
LoanTermVariations loanTermVariation = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate,
264+
adjustedDueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, decimalValue, parent);
265+
pendingRescheduleVariations.add(loanTermVariation);
277266
}
278267

279268
if (rescheduleFromDate != null && interestRate != null) {
280269
LoanTermVariations parent = null;
281270
final Integer termType = LoanTermVariationType.INTEREST_RATE_FROM_INSTALLMENT.getValue();
282-
createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, dueDate,
283-
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, interestRate, parent);
271+
LoanTermVariations loanTermVariation = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate,
272+
dueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, interestRate, parent);
273+
pendingRescheduleVariations.add(loanTermVariation);
284274
}
285275

286276
if (rescheduleFromDate != null && graceOnPrincipal != null) {
@@ -289,32 +279,111 @@ private void createLoanTermVariationsForRegularLoans(final Loan loan, final Inte
289279
parent = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, dueDate,
290280
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, BigDecimal.valueOf(graceOnPrincipal),
291281
parent);
282+
pendingRescheduleVariations.add(parent);
292283

293284
BigDecimal extraTermsBasedOnGracePeriods = BigDecimal.valueOf(graceOnPrincipal);
294-
createLoanTermVariations(loanRescheduleRequest, LoanTermVariationType.EXTEND_REPAYMENT_PERIOD.getValue(), loan,
295-
rescheduleFromDate, dueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment,
296-
extraTermsBasedOnGracePeriods, parent);
285+
LoanTermVariations extraTermsVariation = createLoanTermVariations(loanRescheduleRequest,
286+
LoanTermVariationType.EXTEND_REPAYMENT_PERIOD.getValue(), loan, rescheduleFromDate, dueDate,
287+
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, extraTermsBasedOnGracePeriods, parent);
288+
pendingRescheduleVariations.add(extraTermsVariation);
297289

298290
}
299291

300292
if (rescheduleFromDate != null && graceOnInterest != null) {
301293
LoanTermVariations parent = null;
302294
final Integer termType = LoanTermVariationType.GRACE_ON_INTEREST.getValue();
303-
createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate, dueDate,
304-
loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment, BigDecimal.valueOf(graceOnInterest),
305-
parent);
295+
LoanTermVariations loanTermVariation = createLoanTermVariations(loanRescheduleRequest, termType, loan, rescheduleFromDate,
296+
dueDate, loanRescheduleRequestToTermVariationMappings, isActive, isSpecificToInstallment,
297+
BigDecimal.valueOf(graceOnInterest), parent);
298+
pendingRescheduleVariations.add(loanTermVariation);
306299
}
307300

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

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

0 commit comments

Comments
 (0)