Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public final class GlobalConfigurationConstants {
public static final String AMAZON_S3 = "amazon-s3";
public static final String RESCHEDULE_FUTURE_REPAYMENTS = "reschedule-future-repayments";
public static final String RESCHEDULE_REPAYMENTS_ON_HOLIDAYS = "reschedule-repayments-on-holidays";
public static final String SPLIT_LARGE_LAST_INSTALLMENT_ON_LOAN_RESCHEDULE = "split-large-last-installment-on-loan-reschedule";
public static final String ALLOW_TRANSACTIONS_ON_HOLIDAY = "allow-transactions-on-holiday";
public static final String ALLOW_TRANSACTIONS_ON_NON_WORKING_DAY = "allow-transactions-on-non-workingday";
public static final String CONSTRAINT_APPROACH_FOR_DATATABLES = "constraint-approach-for-datatables";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public interface ConfigurationDomainService {

boolean isRescheduleRepaymentsOnHolidaysEnabled();

boolean isSplitLargeLastInstallmentOnLoanRescheduleEnabled();

boolean allowTransactionsOnHolidayEnabled();

boolean allowTransactionsOnNonWorkingDayEnabled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
+ "reschedule-future-repayments - defaults to false - if true reschedules repayemnts which falls on a non-working day to configured repayment rescheduling rule\n"
+ "allow-transactions-on-non-workingday - defaults to false - if true allows transactions on non-working days\n"
+ "reschedule-repayments-on-holidays - defaults to false - if true reschedules repayemnts which falls on a non-working day to defined reschedule date\n"
+ "split-large-last-installment-on-loan-reschedule - defaults to false - if true adds extra repayment term(s) during loan reschedule to avoid an oversized final installment\n"
+ "allow-transactions-on-holiday - defaults to false - if true allows transactions on holidays\n"
+ "savings-interest-posting-current-period-end - Set it at the database level before any savings interest is posted. When set as false(default), interest will be posted on the first date of next period. If set as true, interest will be posted on last date of current period. There is no difference in the interest amount posted.\n"
+ "financial-year-beginning-month - Set it at the database level before any savings interest is posted. Allowed values 1 - 12 (January - December). Interest posting periods are evaluated based on this configuration.\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ public boolean isRescheduleRepaymentsOnHolidaysEnabled() {
return property.isEnabled();
}

@Override
public boolean isSplitLargeLastInstallmentOnLoanRescheduleEnabled() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
GlobalConfigurationConstants.SPLIT_LARGE_LAST_INSTALLMENT_ON_LOAN_RESCHEDULE);
return property.isEnabled();
}

@Override
public boolean allowTransactionsOnHolidayEnabled() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
Expand All @@ -35,6 +36,7 @@
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
Expand All @@ -47,6 +49,7 @@
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanRescheduledDueAdjustScheduleBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
Expand Down Expand Up @@ -98,8 +101,10 @@
public class LoanRescheduleRequestWritePlatformServiceImpl implements LoanRescheduleRequestWritePlatformService {

private static final DefaultScheduledDateGenerator DEFAULT_SCHEDULED_DATE_GENERATOR = new DefaultScheduledDateGenerator();
private static final int MAX_AUTO_SPLIT_ADDITIONAL_TERMS = 120;

private final CodeValueRepositoryWrapper codeValueRepositoryWrapper;
private final ConfigurationDomainService configurationDomainService;
private final PlatformSecurityContext platformSecurityContext;
@Qualifier("loanRescheduleRequestDataValidator")
private final LoanRescheduleRequestDataValidator loanRescheduleRequestDataValidator;
Expand Down Expand Up @@ -486,12 +491,6 @@ public CommandProcessingResult approve(JsonCommand jsonCommand) {
loanTermVariationsRepository.save(previousLoanTermVariations);
}
}
BigDecimal annualNominalInterestRate = null;
List<LoanTermVariationsData> loanTermVariations = new ArrayList<>();
loanTermVariationsMapper.constructLoanTermVariations(scheduleGeneratorDTO.getFloatingRateDTO(), annualNominalInterestRate,
loanTermVariations, loan);
loanApplicationTerms.getLoanTermVariations().setExceptionData(loanTermVariations);

/*
* for (LoanTermVariationsData loanTermVariation :
* loanApplicationTerms.getLoanTermVariations().getDueDateVariation( )) { if
Expand All @@ -507,10 +506,28 @@ public CommandProcessingResult approve(JsonCommand jsonCommand) {
final MathContext mathContext = MoneyHelper.getMathContext();
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = this.loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(loan.transactionProcessingStrategy());
final LoanScheduleGenerator loanScheduleGenerator = this.loanScheduleFactory.create(loanApplicationTerms.getLoanScheduleType(),
loanApplicationTerms.getInterestMethod());
final LoanScheduleDTO loanScheduleDTO = loanScheduleGenerator.rescheduleNextInstallments(mathContext, loanApplicationTerms,
loan, loanApplicationTerms.getHolidayDetailDTO(), loanRepaymentScheduleTransactionProcessor, rescheduleFromDate);
LoanScheduleDTO loanScheduleDTO = generateRescheduledLoanSchedule(scheduleGeneratorDTO, loan, rescheduleFromDate, mathContext,
loanRepaymentScheduleTransactionProcessor);
if (configurationDomainService.isSplitLargeLastInstallmentOnLoanRescheduleEnabled()) {
int autoAddedTerms = 0;
while (isLastInstallmentAmountTooLarge(loanScheduleDTO, loan.getCurrency(), rescheduleFromDate)
&& autoAddedTerms < MAX_AUTO_SPLIT_ADDITIONAL_TERMS) {
final LocalDate extendFromDate = getLastRescheduledInstallmentDueDate(loanScheduleDTO, rescheduleFromDate);
if (extendFromDate == null) {
log.warn("Unable to determine extension due date for loan reschedule request {}", loanRescheduleRequestId);
break;
}
addAutoExtendRepaymentPeriodVariation(loanRescheduleRequest, loan, extendFromDate);
autoAddedTerms++;
hasInterestRateChange = true;
loanScheduleDTO = generateRescheduledLoanSchedule(scheduleGeneratorDTO, loan, rescheduleFromDate, mathContext,
loanRepaymentScheduleTransactionProcessor);
}
if (autoAddedTerms == MAX_AUTO_SPLIT_ADDITIONAL_TERMS) {
log.warn("Reached auto-split safeguard limit ({}) for loan reschedule request {}", MAX_AUTO_SPLIT_ADDITIONAL_TERMS,
loanRescheduleRequestId);
}
}

// Either the installments got recalculated or the model
if (loanScheduleDTO.getInstallments() != null) {
Expand Down Expand Up @@ -564,6 +581,84 @@ public CommandProcessingResult approve(JsonCommand jsonCommand) {
}
}

private LoanScheduleDTO generateRescheduledLoanSchedule(final ScheduleGeneratorDTO scheduleGeneratorDTO, final Loan loan,
final LocalDate rescheduleFromDate, final MathContext mathContext,
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) {
final LoanApplicationTerms loanApplicationTerms = loanTermVariationsMapper.constructLoanApplicationTerms(scheduleGeneratorDTO,
loan);
final BigDecimal annualNominalInterestRate = null;
final List<LoanTermVariationsData> loanTermVariations = new ArrayList<>();
loanTermVariationsMapper.constructLoanTermVariations(scheduleGeneratorDTO.getFloatingRateDTO(), annualNominalInterestRate,
loanTermVariations, loan);
loanApplicationTerms.getLoanTermVariations().setExceptionData(loanTermVariations);

final LoanScheduleGenerator loanScheduleGenerator = this.loanScheduleFactory.create(loanApplicationTerms.getLoanScheduleType(),
loanApplicationTerms.getInterestMethod());
return loanScheduleGenerator.rescheduleNextInstallments(mathContext, loanApplicationTerms, loan,
loanApplicationTerms.getHolidayDetailDTO(), loanRepaymentScheduleTransactionProcessor, rescheduleFromDate);
}

private void addAutoExtendRepaymentPeriodVariation(final LoanRescheduleRequest loanRescheduleRequest, final Loan loan,
final LocalDate termApplicableFromDate) {
final LoanTermVariations loanTermVariation = new LoanTermVariations(LoanTermVariationType.EXTEND_REPAYMENT_PERIOD.getValue(),
termApplicableFromDate, BigDecimal.ONE, null, false, loan, loan.getStatus().getValue(), true, null);
loan.getLoanTermVariations().add(loanTermVariation);
loanRescheduleRequest.getLoanRescheduleRequestToTermVariationMappings()
.add(LoanRescheduleRequestToTermVariationMapping.createNew(loanRescheduleRequest, loanTermVariation));
}

private LocalDate getLastRescheduledInstallmentDueDate(final LoanScheduleDTO loanScheduleDTO, final LocalDate rescheduleFromDate) {
final List<LoanRepaymentScheduleInstallment> installments = loanScheduleDTO.getInstallments();
if (installments == null || installments.isEmpty()) {
return null;
}
LocalDate lastDueDate = null;
for (LoanRepaymentScheduleInstallment installment : installments) {
if (installment.isDownPayment() || DateUtils.isBefore(installment.getDueDate(), rescheduleFromDate)) {
continue;
}
lastDueDate = installment.getDueDate();
}
return lastDueDate;
}

private boolean isLastInstallmentAmountTooLarge(final LoanScheduleDTO loanScheduleDTO, final MonetaryCurrency currency,
final LocalDate rescheduleFromDate) {
final List<LoanRepaymentScheduleInstallment> installments = loanScheduleDTO.getInstallments();
if (installments == null || installments.size() < 2) {
return false;
}

final List<LoanRepaymentScheduleInstallment> rescheduledInstallments = new ArrayList<>();
for (LoanRepaymentScheduleInstallment installment : installments) {
if (installment.isDownPayment() || DateUtils.isBefore(installment.getDueDate(), rescheduleFromDate)) {
continue;
}
rescheduledInstallments.add(installment);
}

if (rescheduledInstallments.size() < 2) {
return false;
}

final LoanRepaymentScheduleInstallment lastInstallment = rescheduledInstallments.get(rescheduledInstallments.size() - 1);
BigDecimal maxPriorDueAmount = BigDecimal.ZERO;
for (int i = 0; i < rescheduledInstallments.size() - 1; i++) {
final BigDecimal dueAmount = rescheduledInstallments.get(i).getDue(currency).getAmount();
if (dueAmount.compareTo(maxPriorDueAmount) > 0) {
maxPriorDueAmount = dueAmount;
}
}

final BigDecimal lastInstallmentDueAmount = lastInstallment.getDue(currency).getAmount();
// Treat "too large" as a material increase, not rounding noise.
final BigDecimal minimumAbsoluteDifference = BigDecimal.ONE.setScale(currency.getDigitsAfterDecimal(), RoundingMode.UNNECESSARY);
final BigDecimal minimumRelativeDifference = maxPriorDueAmount.multiply(new BigDecimal("0.01"))
.setScale(currency.getDigitsAfterDecimal(), RoundingMode.HALF_UP);
final BigDecimal minimumMaterialDifference = minimumAbsoluteDifference.max(minimumRelativeDifference);
return lastInstallmentDueAmount.subtract(maxPriorDueAmount).compareTo(minimumMaterialDifference) > 0;
Comment on lines +625 to +659
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you help me understand the logic behind this?

I’m curious about what constitutes “too large” in this context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too large here means the last rescheduled installment becomes materially higher than the rest, not just different due to rounding.

My current Logic in this method:
We only inspect installments in the rescheduled range (exclude down payment + due dates before rescheduleFromDate). We take the max due amount among all rescheduled installments except the last. We compare last due vs that max prior due. We treat it as “too large” only if the last due is greater by more than one minor currency unit. So for a 2-decimal currency, the threshold is > 0.01 (strictly greater), which intentionally ignores rounding noise and only flags a real ballooning last installment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont see the value to create a new period with 1 cent or 10 cent EMI just because the last installment was higher slightly then the rest of the periods...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the logic so we only split when the last installment is materially larger. previous logic wasn't useful, thanks for help, I also updated the test to reflect the real E2E effect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please point me to the location of the test which covers the repayment schedule and balances?

}

private Loan saveAndFlushLoanWithDataIntegrityViolationChecks(final Loan loan) {
try {
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,5 @@
<include file="parts/0225_add_originator_external_ids_to_aggregation_summary.xml" relativeToChangelogFile="true" />
<include file="parts/0226_trial_balance_summary_fix_originator_join_conditions.xml" relativeToChangelogFile="true" />
<include file="parts/0227_postgresql_client_and_loan_trends_reports.xml" relativeToChangelogFile="true" />
<include file="parts/0228_add_reschedule_split_large_last_installment_config.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--

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.

-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1">
<insert tableName="c_configuration">
<column name="name" value="split-large-last-installment-on-loan-reschedule"/>
<column name="value" valueNumeric="0"/>
<column name="enabled" valueBoolean="false"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description"
value="If enabled, loan reschedule may add repayment term(s) to avoid an oversized last installment."/>
</insert>
</changeSet>
</databaseChangeLog>
Loading
Loading