From 37797f659354d5a620dabd93762661404e9216e5 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Thu, 11 Dec 2025 14:51:09 +0100 Subject: [PATCH 1/3] FINERACT-2312: full term calculation --- .../domain/LoanApplicationTerms.java | 26 +- .../LoanRepaymentScheduleModelData.java | 3 +- .../mapper/LoanTermVariationsMapper.java | 3 +- .../misc/Main.java | 2 +- ...eProgressiveLoanScheduleGeneratorTest.java | 2 +- .../ProgressiveLoanScheduleGenerator.java | 84 ++++++ .../domain/LoanScheduleGeneratorTest.java | 4 +- .../service/LoanScheduleAssembler.java | 7 +- .../DefaultScheduledDateGeneratorTest.java | 4 +- ...oanDisbursementDetailsIntegrationTest.java | 254 ++++++++++++++++++ 10 files changed, 374 insertions(+), 15 deletions(-) diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java index 560aa83d223..b32e2312c6f 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java @@ -247,6 +247,7 @@ public final class LoanApplicationTerms { private LoanBuyDownFeeStrategy buyDownFeeStrategy; private LoanBuyDownFeeIncomeType buyDownFeeIncomeType; private boolean merchantBuyDownFee; + private boolean allowFullTermForTranche = false; private LoanApplicationTerms(Builder builder) { this.currency = builder.currency; @@ -292,6 +293,7 @@ private LoanApplicationTerms(Builder builder) { this.buyDownFeeStrategy = builder.buyDownFeeStrategy; this.buyDownFeeIncomeType = builder.buyDownFeeIncomeType; this.merchantBuyDownFee = builder.merchantBuyDownFee; + this.allowFullTermForTranche = builder.allowFullTermForTranche; this.interestMethod = builder.interestMethod; this.allowPartialPeriodInterestCalcualtion = builder.allowPartialPeriodInterestCalculation; } @@ -333,6 +335,7 @@ public static class Builder { private LoanBuyDownFeeStrategy buyDownFeeStrategy; private LoanBuyDownFeeIncomeType buyDownFeeIncomeType; private boolean merchantBuyDownFee; + private boolean allowFullTermForTranche; private boolean allowPartialPeriodInterestCalculation; public Builder interestMethod(InterestMethod interestMethod) { @@ -500,6 +503,11 @@ public Builder merchantBuyDownFee(boolean value) { return this; } + public Builder allowFullTermForTranche(boolean value) { + this.allowFullTermForTranche = value; + return this; + } + public LoanApplicationTerms build() { return new LoanApplicationTerms(this); } @@ -542,7 +550,8 @@ public static LoanApplicationTerms assembleFrom(LoanRepaymentScheduleModelData m .submittedOnDate(modelData.scheduleGenerationStartDate()).seedDate(seedDate) .interestRecognitionOnDisbursementDate(modelData.interestRecognitionOnDisbursementDate()) .daysInYearCustomStrategy(modelData.daysInYearCustomStrategy()).interestMethod(modelData.interestMethod()) - .allowPartialPeriodInterestCalculation(modelData.allowPartialPeriodInterestCalculation()).mc(mc).build(); + .allowPartialPeriodInterestCalculation(modelData.allowPartialPeriodInterestCalculation()) + .allowFullTermForTranche(modelData.allowFullTermForTranche()).mc(mc).build(); } public static LoanApplicationTerms assembleFrom(final CurrencyData currency, final Integer loanTermFrequency, @@ -579,7 +588,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin final LoanCapitalizedIncomeStrategy capitalizedIncomeStrategy, final LoanCapitalizedIncomeType capitalizedIncomeType, final boolean enableBuyDownFee, final LoanBuyDownFeeCalculationType buyDownFeeCalculationType, final LoanBuyDownFeeStrategy buyDownFeeStrategy, final LoanBuyDownFeeIncomeType buyDownFeeIncomeType, - final boolean merchantBuyDownFee) { + final boolean merchantBuyDownFee, final boolean allowFullTermForTranche) { final LoanRescheduleStrategyMethod rescheduleStrategyMethod = null; final CalendarHistoryDataWrapper calendarHistoryDataWrapper = null; @@ -601,7 +610,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate, daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, - buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee); + buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, allowFullTermForTranche); } @@ -622,7 +631,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin final boolean isSkipRepaymentOnFirstDayOfMonth, final HolidayDetailDTO holidayDetailDTO, final boolean allowCompoundingOnEod, final boolean isFirstRepaymentDateAllowedOnHoliday, final boolean isInterestToBeRecoveredFirstWhenGreaterThanEMI, final BigDecimal fixedPrincipalPercentagePerInstallment, final boolean isPrincipalCompoundingDisabledForOverdueLoans, - final RepaymentStartDateType repaymentStartDateType, final LocalDate submittedOnDate) { + final RepaymentStartDateType repaymentStartDateType, final LocalDate submittedOnDate, final boolean allowFullTermForTranche) { final Integer numberOfRepayments = loanProductRelatedDetail.getNumberOfRepayments(); final Integer repaymentEvery = loanProductRelatedDetail.getRepayEvery(); @@ -680,7 +689,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin loanProductRelatedDetail.getCapitalizedIncomeStrategy(), loanProductRelatedDetail.getCapitalizedIncomeType(), loanProductRelatedDetail.isEnableBuyDownFee(), loanProductRelatedDetail.getBuyDownFeeCalculationType(), loanProductRelatedDetail.getBuyDownFeeStrategy(), loanProductRelatedDetail.getBuyDownFeeIncomeType(), - loanProductRelatedDetail.isMerchantBuyDownFee()); + loanProductRelatedDetail.isMerchantBuyDownFee(), allowFullTermForTranche); } private LoanApplicationTerms(final CurrencyData currency, final Integer loanTermFrequency, @@ -716,7 +725,7 @@ private LoanApplicationTerms(final CurrencyData currency, final Integer loanTerm final LoanCapitalizedIncomeStrategy capitalizedIncomeStrategy, final LoanCapitalizedIncomeType capitalizedIncomeType, final boolean enableBuyDownFee, final LoanBuyDownFeeCalculationType buyDownFeeCalculationType, final LoanBuyDownFeeStrategy buyDownFeeStrategy, final LoanBuyDownFeeIncomeType buyDownFeeIncomeType, - final boolean merchantBuyDownFee) { + final boolean merchantBuyDownFee, final boolean allowFullTermForTranche) { this.currency = currency; this.loanTermFrequency = loanTermFrequency; @@ -827,6 +836,7 @@ private LoanApplicationTerms(final CurrencyData currency, final Integer loanTerm this.buyDownFeeStrategy = buyDownFeeStrategy; this.buyDownFeeIncomeType = buyDownFeeIncomeType; this.merchantBuyDownFee = merchantBuyDownFee; + this.allowFullTermForTranche = allowFullTermForTranche; } public Money adjustPrincipalIfLastRepaymentPeriod(final Money principalForPeriod, final Money totalCumulativePrincipalToDate, @@ -2256,4 +2266,8 @@ public void updateVariationDays(final long daysToAdd) { this.variationDays += daysToAdd; } + public boolean isAllowFullTermForTranche() { + return allowFullTermForTranche; + } + } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java index fe6b1e055a1..66fca947cbe 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java @@ -35,5 +35,6 @@ public record LoanRepaymentScheduleModelData(@NotNull LocalDate scheduleGenerati @NotNull boolean downPaymentEnabled, @NotNull DaysInMonthType daysInMonth, @NotNull DaysInYearType daysInYear, BigDecimal downPaymentPercentage, Integer installmentAmountInMultiplesOf, Integer fixedLength, @NotNull Boolean interestRecognitionOnDisbursementDate, @Nullable DaysInYearCustomStrategyType daysInYearCustomStrategy, - @NotNull InterestMethod interestMethod, @NotNull boolean allowPartialPeriodInterestCalculation) { + @NotNull InterestMethod interestMethod, @NotNull boolean allowPartialPeriodInterestCalculation, + @NotNull boolean allowFullTermForTranche) { } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java index 7fd5591e755..a4b909c6d6a 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java @@ -120,7 +120,8 @@ public LoanApplicationTerms constructLoanApplicationTerms(final ScheduleGenerato scheduleGeneratorDTO.getNumberOfdays(), scheduleGeneratorDTO.isSkipRepaymentOnFirstDayofMonth(), holidayDetailDTO, allowCompoundingOnEod, scheduleGeneratorDTO.isFirstRepaymentDateAllowedOnHoliday(), scheduleGeneratorDTO.isInterestToBeRecoveredFirstWhenGreaterThanEMI(), loan.getFixedPrincipalPercentagePerInstallment(), - scheduleGeneratorDTO.isPrincipalCompoundingDisabledForOverdueLoans(), repaymentStartDateType, loan.getSubmittedOnDate()); + scheduleGeneratorDTO.isPrincipalCompoundingDisabledForOverdueLoans(), repaymentStartDateType, loan.getSubmittedOnDate(), + loan.isAllowFullTermForTranche()); } private BigDecimal constructFloatingInterestRates(final BigDecimal annualNominalInterestRate, final FloatingRateDTO floatingRateDTO, diff --git a/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java b/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java index 1b7d006eb66..e3fd40de21c 100644 --- a/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java +++ b/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java @@ -62,7 +62,7 @@ public static void main(String[] args) throws InterruptedException { final InterestMethod interestMethod = InterestMethod.DECLINING_BALANCE; final boolean allowPartialPeriodInterestCalculation = true; - var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, dasInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation); + var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, dasInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation, false); final LoanSchedulePlan plan = calculator.generate(mc, config); printPlan(plan); diff --git a/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java b/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java index 0eea0ff98fa..7bf3e57311e 100644 --- a/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java +++ b/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java @@ -67,7 +67,7 @@ void testGenerate() { var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, - daysInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation); + daysInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation, false); final LoanSchedulePlan plan = calculator.generate(mc, config); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java index e67ff4c2369..ce93e230245 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java @@ -35,6 +35,7 @@ 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.organisation.workingdays.data.AdjustedDateDetailsDTO; import org.apache.fineract.portfolio.loanaccount.data.DisbursementData; import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO; @@ -113,6 +114,18 @@ public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTer prepareDisbursementsOnLoanApplicationTerms(loanApplicationTerms); final List disbursementDataList = getSortedDisbursementList(loanApplicationTerms); + if (loanApplicationTerms.isAllowFullTermForTranche() && loanApplicationTerms.isMultiDisburseLoan()) { + ScheduleExtensionResult extensionResult = calculateAdditionalPeriodsForFullTermTranches(disbursementDataList, + expectedRepaymentPeriods, loanApplicationTerms); + if (extensionResult.additionalPeriods > 0) { + List extensionPeriods = generateAdditionalPeriods(mc, extensionResult.additionalPeriods, + expectedRepaymentPeriods, loanApplicationTerms, holidayDetailDTO); + expectedRepaymentPeriods.addAll(extensionPeriods); + emiCalculator.addRepaymentPeriods(interestScheduleModel, extensionResult.disbursementDate, + extensionResult.additionalPeriods); + } + } + for (LoanScheduleModelRepaymentPeriod repaymentPeriod : expectedRepaymentPeriods) { scheduleParams.setPeriodStartDate(repaymentPeriod.getFromDate()); scheduleParams.setActualRepaymentDate(repaymentPeriod.getDueDate()); @@ -502,4 +515,75 @@ private Set separateTotalCompoundingPercentageCharges(final Set disbursementDataList, + final List existingPeriods, final LoanApplicationTerms loanApplicationTerms) { + if (disbursementDataList.size() <= 1) { + return new ScheduleExtensionResult(0, null); + } + + int maxAdditionalPeriods = 0; + LocalDate maxDisbursementDate = null; + final int numberOfRepayments = loanApplicationTerms.getNumberOfRepayments(); + + for (int i = 1; i < disbursementDataList.size(); i++) { + LocalDate disbursementDate = disbursementDataList.get(i).disbursementDate(); + int periodIndex = findPeriodIndexForDate(disbursementDate, existingPeriods); + int additionalPeriodsForThisTranche = periodIndex; + if (additionalPeriodsForThisTranche > maxAdditionalPeriods) { + maxAdditionalPeriods = additionalPeriodsForThisTranche; + maxDisbursementDate = disbursementDate; + } + } + + return new ScheduleExtensionResult(maxAdditionalPeriods, maxDisbursementDate); + } + + private record ScheduleExtensionResult(int additionalPeriods, LocalDate disbursementDate) { + } + + private int findPeriodIndexForDate(final LocalDate date, final List periods) { + for (int i = 0; i < periods.size(); i++) { + LoanScheduleModelRepaymentPeriod period = periods.get(i); + if (!date.isBefore(period.getFromDate()) && date.isBefore(period.getDueDate())) { + return i; + } + } + return periods.size() - 1; + } + + private List generateAdditionalPeriods(final MathContext mc, final int additionalPeriods, + final List existingPeriods, final LoanApplicationTerms loanApplicationTerms, + final HolidayDetailDTO holidayDetailDTO) { + final Money zeroAmount = Money.zero(loanApplicationTerms.getCurrency(), mc); + final List extensionPeriods = new ArrayList<>(additionalPeriods); + + LoanScheduleModelRepaymentPeriod lastPeriod = existingPeriods.get(existingPeriods.size() - 1); + LocalDate lastRepaymentDate = lastPeriod.getDueDate(); + int startingPeriodNumber = existingPeriods.size() + 1; + + for (int i = 0; i < additionalPeriods; i++) { + LocalDate nextRepaymentDate = generateNextRepaymentDate(lastRepaymentDate, loanApplicationTerms, false); + + if (i == additionalPeriods - 1) { + nextRepaymentDate = adjustRepaymentDate(nextRepaymentDate, loanApplicationTerms, holidayDetailDTO).getChangedScheduleDate(); + } + + extensionPeriods.add(LoanScheduleModelRepaymentPeriod.repayment(startingPeriodNumber + i, lastRepaymentDate, nextRepaymentDate, + zeroAmount, zeroAmount, zeroAmount, zeroAmount, zeroAmount, zeroAmount, false, mc)); + lastRepaymentDate = nextRepaymentDate; + } + + return extensionPeriods; + } + + private LocalDate generateNextRepaymentDate(final LocalDate lastRepaymentDate, final LoanApplicationTerms loanApplicationTerms, + final boolean isFirstRepayment) { + return scheduledDateGenerator.generateNextRepaymentDate(lastRepaymentDate, loanApplicationTerms, isFirstRepayment); + } + + private AdjustedDateDetailsDTO adjustRepaymentDate(final LocalDate repaymentDate, final LoanApplicationTerms loanApplicationTerms, + final HolidayDetailDTO holidayDetailDTO) { + return scheduledDateGenerator.adjustRepaymentDate(repaymentDate, loanApplicationTerms, holidayDetailDTO); + } } diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java index e192fe5fe1e..06d86fe6ad1 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java @@ -65,7 +65,7 @@ void testGenerateLoanSchedule() { LoanRepaymentScheduleModelData modelData = new LoanRepaymentScheduleModelData(LocalDate.of(2024, 1, 1), CURRENCY, DISBURSEMENT_AMOUNT, DISBURSEMENT_DATE, NUMBER_OF_REPAYMENTS, REPAYMENT_FREQUENCY, REPAYMENT_FREQUENCY_TYPE, NOMINAL_INTEREST_RATE, false, DaysInMonthType.DAYS_30, DaysInYearType.DAYS_360, null, null, null, false, null, - InterestMethod.DECLINING_BALANCE, true); + InterestMethod.DECLINING_BALANCE, true, false); ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator(); ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(scheduledDateGenerator, emiCalculator, @@ -103,7 +103,7 @@ void testGenerateLoanScheduleWithDownPayment() { LoanRepaymentScheduleModelData modelData = new LoanRepaymentScheduleModelData(LocalDate.of(2024, 1, 1), CURRENCY, DISBURSEMENT_AMOUNT_100, LocalDate.of(2024, 1, 1), NUMBER_OF_REPAYMENTS, REPAYMENT_FREQUENCY, REPAYMENT_FREQUENCY_TYPE, NOMINAL_INTEREST_RATE, true, DaysInMonthType.DAYS_30, DaysInYearType.DAYS_360, DOWN_PAYMENT_PORTION, null, null, false, - null, InterestMethod.DECLINING_BALANCE, true); + null, InterestMethod.DECLINING_BALANCE, true, false); ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator(); ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(scheduledDateGenerator, emiCalculator, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java index bb303f52b66..dac063334cc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java @@ -533,6 +533,11 @@ private LoanApplicationTerms assembleLoanApplicationTermsFrom(final JsonElement .extractBooleanNamed(LoanApiConstants.INTEREST_RECOGNITION_ON_DISBURSEMENT_DATE, element); } + boolean allowFullTermForTranche = loanProduct.isAllowFullTermForTranche(); + if (this.fromApiJsonHelper.parameterExists(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element)) { + allowFullTermForTranche = this.fromApiJsonHelper.extractBooleanNamed(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element); + } + return LoanApplicationTerms.assembleFrom(applicationCurrency.toData(), loanTermFrequency, loanTermPeriodFrequencyType, numberOfRepayments, repaymentEvery, repaymentPeriodFrequencyType, nthDay, weekDayType, amortizationMethod, interestMethod, interestRatePerPeriod, interestRatePeriodFrequencyType, annualNominalInterestRate, interestCalculationPeriodMethod, @@ -561,7 +566,7 @@ private LoanApplicationTerms assembleLoanApplicationTermsFrom(final JsonElement loanProduct.getLoanProductRelatedDetail().getBuyDownFeeCalculationType(), loanProduct.getLoanProductRelatedDetail().getBuyDownFeeStrategy(), loanProduct.getLoanProductRelatedDetail().getBuyDownFeeIncomeType(), - loanProduct.getLoanProductRelatedDetail().isMerchantBuyDownFee()); + loanProduct.getLoanProductRelatedDetail().isMerchantBuyDownFee(), allowFullTermForTranche); } private CalendarInstance createCalendarForSameAsRepayment(final Integer repaymentEvery, diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java index f929fdc8b36..c5fb2085b68 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java @@ -91,7 +91,7 @@ public void test_generateRepaymentPeriods() { DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null, null, ZERO, null, NONE, null, ZERO, EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false, null, false, DISBURSEMENT_DATE, submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null, false, null, null, false, null, false, null, null, - null, false, null, null, null, false); + null, false, null, null, null, false, false); // when List result = underTest.generateRepaymentPeriods(mathContext, expectedDisbursementDate, @@ -172,7 +172,7 @@ private LoanApplicationTerms createLoanApplicationTerms(LocalDate dueRepaymentPe EMPTY_LIST, BigDecimal.valueOf(36_000L), null, DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null, null, ZERO, null, NONE, null, ZERO, EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false, null, false, DISBURSEMENT_DATE, submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null, false, null, null, - false, null, false, null, null, null, false, null, null, null, false); + false, null, false, null, null, null, false, null, null, null, false, false); } private HolidayDetailDTO createHolidayDTO() { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java index 6d683485b8d..01cafed86ef 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java @@ -880,4 +880,258 @@ public void testLoanLevelOverrideOfAllowFullTermForTranche() { assertEquals(false, loanDetails.getAllowFullTermForTranche()); log.info("-------------------LOAN LEVEL OVERRIDE OF allowFullTermForTranche WORKED SUCCESSFULLY-------"); } + + @Test + public void testFullTermTranche_S1_DisbursementOnInstallmentDate() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).withDaysInYear("360").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 February 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 February 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + assertEquals(9, periods.size(), "Total periods should be 9 (2 disbursements + 7 repayment periods)"); + + BigDecimal expectedSingleEMI = new BigDecimal("17.13"); + BigDecimal expectedAggregatedEMI = new BigDecimal("34.26"); + BigDecimal tolerance = new BigDecimal("0.50"); + + for (GetLoansLoanIdRepaymentPeriod period : periods) { + if (period.getPeriod() != null) { + Integer periodNum = period.getPeriod(); + if (periodNum >= 2 && periodNum <= 6) { + BigDecimal actualEMI = period.getTotalDueForPeriod(); + assertTrue(actualEMI.subtract(expectedAggregatedEMI).abs().compareTo(tolerance) <= 0, + "Period " + periodNum + " EMI should be aggregated (~34.26), but was " + actualEMI); + } else if (periodNum == 7) { + BigDecimal actualEMI = period.getTotalDueForPeriod(); + assertTrue(actualEMI.subtract(expectedSingleEMI).abs().compareTo(tolerance) <= 0, + "Period " + periodNum + " EMI should be single tranche only (~17.13), but was " + actualEMI); + } + } + } + + log.info("-------------------S1 TEST: SCHEDULE VALIDATION-------"); + log.info("Expected: 7 repayment periods with overlapping EMIs aggregated"); + } + + @Test + public void testFullTermTranche_S2_MidPeriodDisbursement() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).withDaysInYear("360").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "15 February 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("15 February 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED (MID-PERIOD)-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + assertEquals(9, periods.size(), "Total periods should be 9 (2 disbursements + 7 repayment periods)"); + + BigDecimal expectedAggregatedEMI = new BigDecimal("34.20"); + BigDecimal tolerance = new BigDecimal("0.50"); + + for (GetLoansLoanIdRepaymentPeriod period : periods) { + if (period.getPeriod() != null) { + Integer periodNum = period.getPeriod(); + if (periodNum >= 2 && periodNum <= 6) { + BigDecimal actualEMI = period.getTotalDueForPeriod(); + assertTrue(actualEMI.subtract(expectedAggregatedEMI).abs().compareTo(tolerance) <= 0, + "Period " + periodNum + " EMI should be aggregated (~34.20), but was " + actualEMI); + } + } + } + + log.info("-------------------S2 TEST: SCHEDULE VALIDATION-------"); + log.info("Expected: 7 repayment periods with interest pro-rated for partial period (Feb 15 to Mar 1)"); + } + + @Test + public void testFullTermTranche_S3_BothBeforeFirstRepayment() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).withDaysInYear("360").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "15 January 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("15 January 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED (BEFORE FIRST REPAYMENT)-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + assertEquals(8, periods.size(), "Total periods should be 8 (2 disbursements + 6 repayment periods - NO EXTENSION)"); + + BigDecimal expectedAggregatedEMI = new BigDecimal("34.21"); + BigDecimal tolerance = new BigDecimal("0.50"); + + for (GetLoansLoanIdRepaymentPeriod period : periods) { + if (period.getPeriod() != null) { + Integer periodNum = period.getPeriod(); + BigDecimal actualEMI = period.getTotalDueForPeriod(); + assertTrue(actualEMI.subtract(expectedAggregatedEMI).abs().compareTo(tolerance) <= 0, + "Period " + periodNum + " EMI should be aggregated (~34.21), but was " + actualEMI); + } + } + + log.info("-------------------S3 TEST: SCHEDULE VALIDATION-------"); + log.info("Expected: 6 repayment periods with NO term extension (both tranches finish on Jul 1)"); + log.info("Both disbursements before first repayment date result in same maturity date"); + } + + @Test + public void testFullTermTrancheBackwardCompatibility() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductWithoutFlag = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(false).withDaysInYear("360").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductWithoutFlag); + log.info("------------------LOAN PRODUCT CREATED WITH allowFullTermForTranche=false ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 February 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 February 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + + log.info("-------------------BACKWARD COMPATIBILITY TEST: SCHEDULE VALIDATION-------"); + log.info("Expected: OLD behavior when allowFullTermForTranche=false"); + log.info("Schedule should NOT use full term tranche logic - should match existing multi-disburse behavior"); + } } From 8d1e9d518b7a9e1e500846b2096ae463685ae7fe Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Fri, 12 Dec 2025 16:32:30 +0100 Subject: [PATCH 2/3] FINERACT-2412: added e2e tests for full term calculation --- .../data/loanproduct/DefaultLoanProduct.java | 4 + .../LoanProductGlobalInitializerStep.java | 33 +++++ .../fineract/test/support/TestContextKey.java | 1 + .../LoanDelayedScheduleCaptures.feature | 139 ++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index 4c722d04e6c..c5ab30f5402 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -179,7 +179,11 @@ public enum DefaultLoanProduct implements LoanProduct { LP1_INTEREST_FLAT_DAILY_RECALCULATION_SAR_MULTIDISB_EXPECT_TRANCHES, // LP1_INTEREST_FLAT_DAILY_ACTUAL_ACTUAL_MULTIDISB_EXPECT_TRANCHES, // LP2_ADV_PYMNT_360_30_ZERO_INTEREST_CHARGE_OFF_ACCRUAL_ACTIVITY, // +<<<<<<< HEAD LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_PRINCIPAL_FIRST, // +======= + LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE, // +>>>>>>> be6317764 (FINERACT-2412: added e2e tests for full term calculation) ; @Override diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index cafc1bac699..c8d10db39b0 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -4262,6 +4262,39 @@ public void initialize() throws Exception { TestContext.INSTANCE.set( TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADVANCED_CUSTOM_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE_PRINCIPAL_FIRST, responseLoanProductsRequestAdvCustomPaymentAllocationProgressiveLoanSchedulePrincipalFirst); + + // LP2 with progressive loan schedule + horizontal + interest recalculation daily EMI + 360/30 + + // multidisbursement with full term tranche enabled + // Frequency for recalculate Outstanding Principal: Daily, Frequency Interval for recalculation: 1 + // (LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE) + String name152 = DefaultLoanProduct.LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE + .getName(); + PostLoanProductsRequest loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche = loanProductsRequestFactory + .defaultLoanProductsRequestLP2Emi()// + .name(name152)// + .daysInYearType(DaysInYearType.DAYS360.value)// + .daysInMonthType(DaysInMonthType.DAYS30.value)// + .isInterestRecalculationEnabled(true)// + .preClosureInterestCalculationStrategy(1)// + .rescheduleStrategyMethod(4)// + .interestRecalculationCompoundingMethod(0)// + .recalculationRestFrequencyType(2)// + .recalculationRestFrequencyInterval(1)// + .paymentAllocation(List.of(// + createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), // + createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"), // + createPaymentAllocation("MERCHANT_ISSUED_REFUND", "REAMORTIZATION"), // + createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT")))// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(false)// + .allowFullTermForTranche(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0);// + PostLoanProductsResponse responseLoanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche = createLoanProductIdempotent( + loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE, + responseLoanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche); } public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 062b6a19d46..2d028f01b6b 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -292,4 +292,5 @@ public abstract class TestContextKey { public static final String LP1_INTEREST_FLAT_DAILY_ACTUAL_ACTUAL_MULTIDISB_EXPECT_TRANCHES = "loanProductCreateResponseLP1InterestFlatDailyActualActualMultiDisbursementExpectTranches"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_360_30_ZERO_INTEREST_CHARGE_OFF_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentZeroInterestChargeOffBehaviourAccrualActivity"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADVANCED_CUSTOM_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE_PRINCIPAL_FIRST = "loanProductCreateResponseLP2AdvancedPaymentHorizontalPrincipalFirst"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyMultidisburseFullTermTranche"; } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature new file mode 100644 index 00000000000..43c5cec8fb1 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature @@ -0,0 +1,139 @@ +@DelayedScheduleCapturesFeature +Feature: Full Term Tranche - Schedule handling and Calculations + + @TestRailId:C4366 + Scenario: Verify full term tranche interest bearing progressive loan - Schedule handling and Calculations - Disbursement on Installment Date - UC1 + 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 disbursements details and 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 | 1st_tranche_disb_expected_date | 1st_tranche_disb_principal | 2nd_tranche_disb_expected_date | 2nd_tranche_disb_principal | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE | 01 January 2024 | 200 | 9.4822 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 100.0 | 01 February 2024 | 100.0 | + And Admin successfully approves the loan on "01 January 2024" with "200" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "100" 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 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 83.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 2 | 29 | 01 March 2024 | | 67.19 | 16.47 | 0.66 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 3 | 31 | 01 April 2024 | | 50.59 | 16.6 | 0.53 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 4 | 30 | 01 May 2024 | | 33.86 | 16.73 | 0.4 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 5 | 31 | 01 June 2024 | | 17.0 | 16.86 | 0.27 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 100.0 | 2.78 | 0.0 | 0.0 | 102.78 | 0.0 | 0.0 | 0.0 | 102.78 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | +# --- 2nd disbursement on installment date --- + When Admin sets the business date to "01 February 2024" + When Admin successfully disburse the loan on "01 February 2024" with "100" EUR transaction amount + Then Loan Repayment schedule has 8 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 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 83.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | | | 01 February 2024 | | 183.66 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 29 | 01 March 2024 | | 150.85 | 32.81 | 1.45 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 3 | 31 | 01 April 2024 | | 117.78 | 33.07 | 1.19 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 4 | 30 | 01 May 2024 | | 84.45 | 33.33 | 0.93 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 5 | 31 | 01 June 2024 | | 50.86 | 33.59 | 0.67 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 6 | 30 | 01 July 2024 | | 17.0 | 33.86 | 0.4 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 7 | 31 | 01 August 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 200.0 | 5.56 | 0.0 | 0.0 | 205.56 | 0.0 | 0.0 | 0.0 | 205.56 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | + | 01 February 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | false | false | + + @TestRailId:C4367 + Scenario: Verify full term tranche interest bearing progressive loan - Schedule handling and Calculations - Disbursement mid-period - UC2 + 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 disbursements details and 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 | 1st_tranche_disb_expected_date | 1st_tranche_disb_principal | 2nd_tranche_disb_expected_date | 2nd_tranche_disb_principal | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE | 01 January 2024 | 200 | 9.4822 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 100.0 | 15 February 2024 | 100.0 | + And Admin successfully approves the loan on "01 January 2024" with "200" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "100" 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 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 83.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 2 | 29 | 01 March 2024 | | 67.19 | 16.47 | 0.66 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 3 | 31 | 01 April 2024 | | 50.59 | 16.6 | 0.53 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 4 | 30 | 01 May 2024 | | 33.86 | 16.73 | 0.4 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 5 | 31 | 01 June 2024 | | 17.0 | 16.86 | 0.27 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 100.0 | 2.78 | 0.0 | 0.0 | 102.78 | 0.0 | 0.0 | 0.0 | 102.78 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | +# --- 2nd disbursement mid-period (Feb 15) --- + When Admin sets the business date to "15 February 2024" + When Admin successfully disburse the loan on "15 February 2024" with "100" EUR transaction amount + Then Loan Repayment schedule has 8 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 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 83.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | | | 15 February 2024 | | 183.66 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 29 | 01 March 2024 | | 150.59 | 33.07 | 1.13 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 3 | 31 | 01 April 2024 | | 117.58 | 33.01 | 1.19 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 4 | 30 | 01 May 2024 | | 84.31 | 33.27 | 0.93 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 5 | 31 | 01 June 2024 | | 50.78 | 33.53 | 0.67 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 6 | 30 | 01 July 2024 | | 16.92 | 33.86 | 0.4 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 7 | 31 | 01 August 2024 | | 0.0 | 16.92 | 0.13 | 0.0 | 0.0 | 17.05 | 0.0 | 0.0 | 0.0 | 17.05 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 200.0 | 5.24 | 0.0 | 0.0 | 205.24 | 0.0 | 0.0 | 0.0 | 205.24 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | + | 15 February 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | false | false | + + @TestRailId:C4368 + Scenario: Verify full term tranche interest bearing progressive loan - Schedule handling and Calculations - Both disbursements before first repayment - UC3 + 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 disbursements details and 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 | 1st_tranche_disb_expected_date | 1st_tranche_disb_principal | 2nd_tranche_disb_expected_date | 2nd_tranche_disb_principal | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE | 01 January 2024 | 200 | 9.4822 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 100.0 | 15 January 2024 | 100.0 | + And Admin successfully approves the loan on "01 January 2024" with "200" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "100" 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 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 83.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 2 | 29 | 01 March 2024 | | 67.19 | 16.47 | 0.66 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 3 | 31 | 01 April 2024 | | 50.59 | 16.6 | 0.53 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 4 | 30 | 01 May 2024 | | 33.86 | 16.73 | 0.4 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 5 | 31 | 01 June 2024 | | 17.0 | 16.86 | 0.27 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 100.0 | 2.78 | 0.0 | 0.0 | 102.78 | 0.0 | 0.0 | 0.0 | 102.78 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | +# --- 2nd disbursement before first repayment date (Jan 15) - no term extension --- + When Admin sets the business date to "15 January 2024" + When Admin successfully disburse the loan on "15 January 2024" with "100" EUR transaction amount + Then Loan Repayment schedule 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 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | | | 15 January 2024 | | 200.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 167.04 | 32.96 | 1.25 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | + | 2 | 29 | 01 March 2024 | | 134.15 | 32.89 | 1.32 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | + | 3 | 31 | 01 April 2024 | | 101.0 | 33.15 | 1.06 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | + | 4 | 30 | 01 May 2024 | | 67.59 | 33.41 | 0.8 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | + | 5 | 31 | 01 June 2024 | | 33.92 | 33.67 | 0.54 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | + | 6 | 30 | 01 July 2024 | | 0.0 | 33.92 | 0.26 | 0.0 | 0.0 | 34.18 | 0.0 | 0.0 | 0.0 | 34.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 200.0 | 5.23 | 0.0 | 0.0 | 205.23 | 0.0 | 0.0 | 0.0 | 205.23 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | + | 15 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | false | false | From b4ad7aeb0d0bfc74f440d9a6eece645ba017135d Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Thu, 18 Dec 2025 14:48:46 +0100 Subject: [PATCH 3/3] FINERACT-2412: add full term tranche calculations --- .../data/loanproduct/DefaultLoanProduct.java | 3 - .../LoanDelayedScheduleCaptures.feature | 20 +-- .../RepaymentScheduleRelatedLoanData.java | 13 ++ .../data/LoanSchedulePeriodData.java | 9 +- ...edPaymentScheduleTransactionProcessor.java | 6 + .../ProgressiveLoanScheduleGenerator.java | 29 ++++- .../calc/ProgressiveEMICalculator.java | 119 ++++++++++++++++-- .../ProgressiveLoanInterestScheduleModel.java | 20 ++- .../calc/data/RepaymentPeriod.java | 16 +++ .../calc/ProgressiveEMICalculatorTest.java | 69 ++++++++++ .../loanaccount/api/LoansApiResource.java | 6 +- .../service/LoanReadPlatformServiceImpl.java | 2 +- .../service/LoanRepaymentScheduleService.java | 34 +++-- ...oanDisbursementDetailsIntegrationTest.java | 106 ++++++++-------- 14 files changed, 356 insertions(+), 96 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index c5ab30f5402..7a0f594c598 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -179,11 +179,8 @@ public enum DefaultLoanProduct implements LoanProduct { LP1_INTEREST_FLAT_DAILY_RECALCULATION_SAR_MULTIDISB_EXPECT_TRANCHES, // LP1_INTEREST_FLAT_DAILY_ACTUAL_ACTUAL_MULTIDISB_EXPECT_TRANCHES, // LP2_ADV_PYMNT_360_30_ZERO_INTEREST_CHARGE_OFF_ACCRUAL_ACTIVITY, // -<<<<<<< HEAD LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_PRINCIPAL_FIRST, // -======= LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE, // ->>>>>>> be6317764 (FINERACT-2412: added e2e tests for full term calculation) ; @Override diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature index 43c5cec8fb1..32d7d3c85bd 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature @@ -28,7 +28,7 @@ Feature: Full Term Tranche - Schedule handling and Calculations # --- 2nd disbursement on installment date --- When Admin sets the business date to "01 February 2024" When Admin successfully disburse the loan on "01 February 2024" with "100" EUR transaction amount - Then Loan Repayment schedule has 8 periods, with the following data for periods: + Then Loan Repayment schedule 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 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | | 1 | 31 | 01 February 2024 | | 83.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | @@ -74,7 +74,7 @@ Feature: Full Term Tranche - Schedule handling and Calculations # --- 2nd disbursement mid-period (Feb 15) --- When Admin sets the business date to "15 February 2024" When Admin successfully disburse the loan on "15 February 2024" with "100" EUR transaction amount - Then Loan Repayment schedule has 8 periods, with the following data for periods: + Then Loan Repayment schedule 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 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | | 1 | 31 | 01 February 2024 | | 83.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | @@ -120,19 +120,19 @@ Feature: Full Term Tranche - Schedule handling and Calculations # --- 2nd disbursement before first repayment date (Jan 15) - no term extension --- When Admin sets the business date to "15 January 2024" When Admin successfully disburse the loan on "15 January 2024" with "100" EUR transaction amount - Then Loan Repayment schedule has 7 periods, with the following data for periods: + 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 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | | | | 15 January 2024 | | 200.0 | | | 0.0 | | 0.0 | 0.0 | | | | - | 1 | 31 | 01 February 2024 | | 167.04 | 32.96 | 1.25 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | - | 2 | 29 | 01 March 2024 | | 134.15 | 32.89 | 1.32 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | - | 3 | 31 | 01 April 2024 | | 101.0 | 33.15 | 1.06 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | - | 4 | 30 | 01 May 2024 | | 67.59 | 33.41 | 0.8 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | - | 5 | 31 | 01 June 2024 | | 33.92 | 33.67 | 0.54 | 0.0 | 0.0 | 34.21 | 0.0 | 0.0 | 0.0 | 34.21 | - | 6 | 30 | 01 July 2024 | | 0.0 | 33.92 | 0.26 | 0.0 | 0.0 | 34.18 | 0.0 | 0.0 | 0.0 | 34.18 | + | 1 | 31 | 01 February 2024 | | 167.02 | 32.98 | 1.22 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 2 | 29 | 01 March 2024 | | 134.14 | 32.88 | 1.32 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 3 | 31 | 01 April 2024 | | 101.0 | 33.14 | 1.06 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 4 | 30 | 01 May 2024 | | 67.6 | 33.4 | 0.8 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 5 | 31 | 01 June 2024 | | 33.93 | 33.67 | 0.53 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 6 | 30 | 01 July 2024 | | 0.0 | 33.93 | 0.27 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | Then Loan Repayment schedule has the following data in Total row: | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | - | 200.0 | 5.23 | 0.0 | 0.0 | 205.23 | 0.0 | 0.0 | 0.0 | 205.23 | + | 200.0 | 5.2 | 0.0 | 0.0 | 205.2 | 0.0 | 0.0 | 0.0 | 205.2 | Then Loan Transactions tab has the following data: | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java index c4997ca9d83..6e672b161ee 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java @@ -34,10 +34,18 @@ public class RepaymentScheduleRelatedLoanData { private final BigDecimal netDisbursalAmount; private final BigDecimal inArrearsTolerance; private final BigDecimal totalFeeChargesAtDisbursement; + private final boolean allowFullTermForTranche; public RepaymentScheduleRelatedLoanData(final LocalDate expectedDisbursementDate, final LocalDate actualDisbursementDate, final CurrencyData currency, final BigDecimal principal, final BigDecimal inArrearsTolerance, final BigDecimal totalFeeChargesAtDisbursement) { + this(expectedDisbursementDate, actualDisbursementDate, currency, principal, inArrearsTolerance, totalFeeChargesAtDisbursement, + false); + } + + public RepaymentScheduleRelatedLoanData(final LocalDate expectedDisbursementDate, final LocalDate actualDisbursementDate, + final CurrencyData currency, final BigDecimal principal, final BigDecimal inArrearsTolerance, + final BigDecimal totalFeeChargesAtDisbursement, final boolean allowFullTermForTranche) { this.expectedDisbursementDate = expectedDisbursementDate; this.actualDisbursementDate = actualDisbursementDate; this.currency = currency; @@ -45,6 +53,7 @@ public RepaymentScheduleRelatedLoanData(final LocalDate expectedDisbursementDate this.inArrearsTolerance = inArrearsTolerance; this.totalFeeChargesAtDisbursement = totalFeeChargesAtDisbursement; this.netDisbursalAmount = this.principal.subtract(this.totalFeeChargesAtDisbursement); + this.allowFullTermForTranche = allowFullTermForTranche; } public LocalDate disbursementDate() { @@ -80,4 +89,8 @@ public DisbursementData disbursementData() { return new DisbursementData(null, null, this.expectedDisbursementDate, this.actualDisbursementDate, this.principal, this.netDisbursalAmount, null, null, waivedChargeAmount); } + + public boolean isAllowFullTermForTranche() { + return this.allowFullTermForTranche; + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/LoanSchedulePeriodData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/LoanSchedulePeriodData.java index 65aac943cbb..324ece734b7 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/LoanSchedulePeriodData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/data/LoanSchedulePeriodData.java @@ -81,9 +81,16 @@ public final class LoanSchedulePeriodData { public static LoanSchedulePeriodData disbursementOnlyPeriod(final LocalDate disbursementDate, final BigDecimal principalDisbursed, final BigDecimal feeChargesDueAtTimeOfDisbursement, final boolean isDisbursed) { + return disbursementOnlyPeriod(disbursementDate, principalDisbursed, feeChargesDueAtTimeOfDisbursement, isDisbursed, + principalDisbursed); + } + + public static LoanSchedulePeriodData disbursementOnlyPeriod(final LocalDate disbursementDate, final BigDecimal principalDisbursed, + final BigDecimal feeChargesDueAtTimeOfDisbursement, final boolean isDisbursed, + final BigDecimal principalLoanBalanceOutstanding) { return builder().dueDate(disbursementDate) // .principalDisbursed(principalDisbursed) // - .principalLoanBalanceOutstanding(principalDisbursed) // + .principalLoanBalanceOutstanding(principalLoanBalanceOutstanding) // .feeChargesDue(feeChargesDueAtTimeOfDisbursement) // .feeChargesPaid(isDisbursed ? feeChargesDueAtTimeOfDisbursement : null) // .feeChargesOutstanding(isDisbursed ? null : feeChargesDueAtTimeOfDisbursement) // diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index be883e45ebd..f1c30afb82c 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -227,6 +227,12 @@ public Pair repr final Integer installmentAmountInMultiplesOf = loan.getLoanProductRelatedDetail().getInstallmentAmountInMultiplesOf(); ProgressiveLoanInterestScheduleModel scheduleModel = emiCalculator.generateInstallmentInterestScheduleModel(installments, LoanConfigurationDetailsMapper.map(loan), installmentAmountInMultiplesOf, overpaymentHolder.getMoneyObject().getMc()); + + scheduleModel.allowFullTermForTranche(loan.isAllowFullTermForTranche()); + if (loan.isAllowFullTermForTranche()) { + scheduleModel.originalNumberOfRepayments(loan.getNumberOfRepayments()); + } + ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder, changedTransactionDetail, scheduleModel); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java index ce93e230245..976ea61bac8 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/ProgressiveLoanScheduleGenerator.java @@ -109,6 +109,10 @@ public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTer final ProgressiveLoanInterestScheduleModel interestScheduleModel = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanApplicationTerms.toLoanConfigurationDetails(), loanApplicationTerms.getInstallmentAmountInMultiplesOf(), mc); + + interestScheduleModel.allowFullTermForTranche(loanApplicationTerms.isAllowFullTermForTranche()); + interestScheduleModel.originalNumberOfRepayments(loanApplicationTerms.getNumberOfRepayments()); + final List periods = new ArrayList<>(expectedRepaymentPeriods.size()); prepareDisbursementsOnLoanApplicationTerms(loanApplicationTerms); @@ -122,7 +126,7 @@ public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTer expectedRepaymentPeriods, loanApplicationTerms, holidayDetailDTO); expectedRepaymentPeriods.addAll(extensionPeriods); emiCalculator.addRepaymentPeriods(interestScheduleModel, extensionResult.disbursementDate, - extensionResult.additionalPeriods); + extensionResult.additionalPeriods, List.of()); } } @@ -309,7 +313,17 @@ private void processDisbursements(final LoanApplicationTerms loanApplicationTerm final List periods, final BigDecimal chargesDueAtTimeOfDisbursement, final boolean includeDisbursementsAfterMaturityDate, final MathContext mc) { + // Check if any disbursement has actually occurred (only relevant for Full Term Tranche) + boolean hasAnyDisbursement = loanApplicationTerms.isAllowFullTermForTranche() + && disbursementDataList.stream().anyMatch(DisbursementData::isDisbursed); + for (DisbursementData disbursementData : disbursementDataList) { + // For Full Term Tranche loans only: if at least one disbursement has occurred, + // skip expected (undisbursed) tranches. For EXPECT_TRANCHE loans, always include + // all expected tranches for EMI calculation. + if (hasAnyDisbursement && !disbursementData.isDisbursed()) { + continue; + } final LocalDate disbursementDate = disbursementData.disbursementDate(); final LocalDate periodFromDate = scheduleParams.getPeriodStartDate(); final LocalDate periodDueDate = scheduleParams.getActualRepaymentDate(); @@ -525,11 +539,20 @@ private ScheduleExtensionResult calculateAdditionalPeriodsForFullTermTranches(fi int maxAdditionalPeriods = 0; LocalDate maxDisbursementDate = null; final int numberOfRepayments = loanApplicationTerms.getNumberOfRepayments(); + final int currentPeriodCount = existingPeriods.size(); + // For each subsequent tranche, calculate how many additional periods are needed + // Each tranche needs 'numberOfRepayments' periods starting from its disbursement period for (int i = 1; i < disbursementDataList.size(); i++) { - LocalDate disbursementDate = disbursementDataList.get(i).disbursementDate(); + DisbursementData disbursementData = disbursementDataList.get(i); + // Skip expected disbursements that haven't actually been disbursed yet + if (!disbursementData.isDisbursed()) { + continue; + } + LocalDate disbursementDate = disbursementData.disbursementDate(); int periodIndex = findPeriodIndexForDate(disbursementDate, existingPeriods); - int additionalPeriodsForThisTranche = periodIndex; + int lastRequiredPeriodIndex = periodIndex + numberOfRepayments - 1; + int additionalPeriodsForThisTranche = Math.max(0, lastRequiredPeriodIndex - currentPeriodCount + 1); if (additionalPeriodsForThisTranche > maxAdditionalPeriods) { maxAdditionalPeriods = additionalPeriodsForThisTranche; maxDisbursementDate = disbursementDate; diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java index 55faa01d8a1..cf70e5f5f1c 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -131,15 +131,100 @@ public void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleM } private void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) { - scheduleModel.repaymentPeriods().stream().filter(rp -> !operation.getSubmittedOnDate().isAfter(rp.getFromDate())) - .forEach(rp -> rp.setTotalDisbursedAmount(rp.getTotalDisbursedAmount().add(operation.getAmount()))); + scheduleModel.repaymentPeriods().stream().filter(rp -> !operation.getSubmittedOnDate().isAfter(rp.getFromDate())).forEach(rp -> { + rp.setTotalDisbursedAmount(rp.getTotalDisbursedAmount().add(operation.getAmount())); + }); - scheduleModel - .changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), operation.getAmount(), - scheduleModel.zero(), scheduleModel.zero()) - .ifPresent((repaymentPeriod) -> calculateEMIValueAndRateFactors( - getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, operation.getSubmittedOnDate()), scheduleModel, - operation)); + if (scheduleModel.allowFullTermForTranche() && scheduleModel.originalNumberOfRepayments() > 0) { + addFullTermTrancheDisbursement(scheduleModel, operation); + } else { + scheduleModel + .changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), operation.getAmount(), + scheduleModel.zero(), scheduleModel.zero()) + .ifPresent((repaymentPeriod) -> calculateEMIValueAndRateFactors( + getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, operation.getSubmittedOnDate()), scheduleModel, + operation)); + } + } + + private void addFullTermTrancheDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, + final EmiChangeOperation operation) { + final MathContext mc = scheduleModel.mc(); + final LocalDate disbursementDate = operation.getSubmittedOnDate(); + final Money disbursedAmount = operation.getAmount(); + final int originalNumberOfRepayments = scheduleModel.originalNumberOfRepayments(); + + scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(disbursementDate, disbursedAmount, scheduleModel.zero(), + scheduleModel.zero()); + + int disbursementPeriodIndex = findDisbursementPeriodIndex(scheduleModel, disbursementDate); + if (disbursementPeriodIndex < 0) { + disbursementPeriodIndex = 0; + } + + int requiredEndIndex = disbursementPeriodIndex + originalNumberOfRepayments; + int currentPeriodCount = scheduleModel.repaymentPeriods().size(); + if (requiredEndIndex > currentPeriodCount) { + extendScheduleForFullTermTranche(scheduleModel, requiredEndIndex - currentPeriodCount); + } + + List trancheRepaymentPeriods = scheduleModel.repaymentPeriods().subList(disbursementPeriodIndex, + disbursementPeriodIndex + originalNumberOfRepayments); + + if (trancheRepaymentPeriods.isEmpty()) { + return; + } + + calculateRateFactorForPeriods(trancheRepaymentPeriods, scheduleModel); + + RepaymentPeriod firstTranchePeriod = trancheRepaymentPeriods.get(0); + boolean isMidPeriodDisbursement = disbursementDate.isAfter(firstTranchePeriod.getFromDate()); + + final BigDecimal rateFactorN; + final BigDecimal fnResult; + if (isMidPeriodDisbursement) { + // Mid-period disbursement: filter rate factors to only include interest from disbursement date + rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorPlus1NFromDate(trancheRepaymentPeriods, disbursementDate, mc)); + // fnResult skips the first period, so no filtering needed + fnResult = MathUtil.stripTrailingZeros(calculateFnResult(trancheRepaymentPeriods, mc)); + } else { + // Period-start disbursement: use regular calculation + rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorPlus1N(trancheRepaymentPeriods, mc)); + fnResult = MathUtil.stripTrailingZeros(calculateFnResult(trancheRepaymentPeriods, mc)); + } + + final Money trancheEmi = Money.of(disbursedAmount.getCurrencyData(), + calculateEMIValue(rateFactorN, disbursedAmount.getAmount(), fnResult, mc), mc); + final Money finalTrancheEmi = applyInstallmentAmountInMultiplesOf(scheduleModel, trancheEmi); + + for (RepaymentPeriod period : trancheRepaymentPeriods) { + Money existingEmi = period.getEmi() != null ? period.getEmi() : scheduleModel.zero(); + Money newEmi = existingEmi.plus(finalTrancheEmi, mc); + period.setEmi(newEmi); + period.setOriginalEmi(newEmi); + } + + calculateOutstandingBalance(scheduleModel); + calculateLastUnpaidRepaymentPeriodEMI(scheduleModel, disbursementDate); + } + + private void extendScheduleForFullTermTranche(final ProgressiveLoanInterestScheduleModel scheduleModel, int periodsToAdd) { + final int existingCount = scheduleModel.repaymentPeriods().size(); + final LocalDate startDate = scheduleModel.getStartDate(); + final List newPeriodDates = generateAdditionalRepaymentPeriodDueDates(scheduleModel, periodsToAdd, existingCount, + scheduleModel.resolveRepaymentPeriodLengthGeneratorFunction(startDate)); + updateModel(scheduleModel, newPeriodDates, LocalDateInterval::startDate, LocalDateInterval::endDate); + } + + private int findDisbursementPeriodIndex(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate disbursementDate) { + List periods = scheduleModel.repaymentPeriods(); + for (int i = 0; i < periods.size(); i++) { + RepaymentPeriod period = periods.get(i); + if (!disbursementDate.isBefore(period.getFromDate()) && disbursementDate.isBefore(period.getDueDate())) { + return i; + } + } + return periods.size() - 1; } private LocalDate getEffectiveRepaymentDueDate(final ProgressiveLoanInterestScheduleModel scheduleModel, @@ -1561,6 +1646,24 @@ private BigDecimal calculateFnResult(final List periods, final .reduce(BigDecimal.ONE, (previousFnValue, currentRateFactor) -> fnValue(previousFnValue, currentRateFactor, mc));// } + /** + * Calculate Rate Factor Product from rate factors, filtering the first period's interest periods by date. For Full + * Term Tranche calculations where a disbursement occurs mid-period, this ensures only interest periods from the + * disbursement date are included. + */ + private BigDecimal calculateRateFactorPlus1NFromDate(final List periods, final LocalDate fromDate, + final MathContext mc) { + if (periods.isEmpty()) { + return BigDecimal.ONE; + } + // First period uses filtered rate factor (only interest periods from disbursement date) + BigDecimal firstPeriodRateFactor = periods.get(0).getRateFactorPlus1FromDate(fromDate); + // Remaining periods use full rate factor + BigDecimal remainingRateFactors = periods.stream().skip(1).map(RepaymentPeriod::getRateFactorPlus1).reduce(BigDecimal.ONE, + (BigDecimal acc, BigDecimal value) -> acc.multiply(value, mc)); + return firstPeriodRateFactor.multiply(remainingRateFactors, mc); + } + /** * Calculate the EMI (Equal Monthly Installment) value */ diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java index 0af304fc0e4..7290130d3a1 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/ProgressiveLoanInterestScheduleModel.java @@ -69,6 +69,12 @@ public class ProgressiveLoanInterestScheduleModel { @Setter private LocalDate lastOverdueBalanceChange; + @Setter + private boolean allowFullTermForTranche = false; + + @Setter + private int originalNumberOfRepayments; + public ProgressiveLoanInterestScheduleModel(final List repaymentPeriods, final ILoanConfigurationDetails loanProductRelatedDetail, final Integer installmentAmountInMultiplesOf, final MathContext mc) { this.repaymentPeriods = new ArrayList<>(repaymentPeriods); @@ -96,15 +102,21 @@ private ProgressiveLoanInterestScheduleModel(final List repayme } public ProgressiveLoanInterestScheduleModel deepCopy(MathContext mc) { - return new ProgressiveLoanInterestScheduleModel(repaymentPeriods, interestRates, loanProductRelatedDetail, - installmentAmountInMultiplesOf, mc, false); + ProgressiveLoanInterestScheduleModel copy = new ProgressiveLoanInterestScheduleModel(repaymentPeriods, interestRates, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc, false); + copy.allowFullTermForTranche(this.allowFullTermForTranche); + copy.originalNumberOfRepayments(this.originalNumberOfRepayments); + return copy; } public ProgressiveLoanInterestScheduleModel copyWithoutPaidAmounts() { final List repaymentPeriodCopies = copyRepaymentPeriods(repaymentPeriods, (previousPeriod, repaymentPeriod) -> RepaymentPeriod.copyWithoutPaidAmounts(previousPeriod, repaymentPeriod, mc)); - return new ProgressiveLoanInterestScheduleModel(repaymentPeriodCopies, interestRates, loanProductRelatedDetail, - installmentAmountInMultiplesOf, mc, true); + ProgressiveLoanInterestScheduleModel copy = new ProgressiveLoanInterestScheduleModel(repaymentPeriodCopies, interestRates, + loanProductRelatedDetail, installmentAmountInMultiplesOf, mc, true); + copy.allowFullTermForTranche(this.allowFullTermForTranche); + copy.originalNumberOfRepayments(this.originalNumberOfRepayments); + return copy; } private List copyRepaymentPeriods(final List repaymentPeriods, diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java index 755f47a74d6..11159bd89d7 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java @@ -200,6 +200,19 @@ private BigDecimal calculateRateFactorPlus1() { return interestPeriods.stream().map(InterestPeriod::getRateFactor).reduce(BigDecimal.ONE, BigDecimal::add); } + /** + * This method gives back sum of (Rate Factor + 1) from interest periods that start on or after the given date. Used + * for Full Term Tranche calculations where a disbursement occurs mid-period. + * + * @param fromDate + * the date from which to include interest periods + * @return rate factor plus 1 for filtered interest periods + */ + public BigDecimal getRateFactorPlus1FromDate(LocalDate fromDate) { + return interestPeriods.stream().filter(ip -> !ip.getFromDate().isBefore(fromDate)).map(InterestPeriod::getRateFactor) + .reduce(BigDecimal.ONE, BigDecimal::add); + } + /** * Gives back calculated due interest + credited interest * @@ -351,6 +364,9 @@ public Money getCreditedAmounts() { public Money getOutstandingLoanBalance() { if (outstandingBalanceCalculation == null) { outstandingBalanceCalculation = Memo.of(() -> { + if (getInterestPeriods().isEmpty()) { + return getZero(); + } InterestPeriod lastInterestPeriod = getInterestPeriods().getLast(); Money calculatedOutStandingLoanBalance = lastInterestPeriod.getOutstandingLoanBalance() // .plus(lastInterestPeriod.getBalanceCorrectionAmount(), getMc()) // diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java index c67c5a8bd0f..080af341a30 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java @@ -5152,4 +5152,73 @@ private ProgressiveLoanInterestScheduleModel copyJson(ProgressiveLoanInterestSch return interestScheduleModelService.fromJson(json, toCopy.loanProductRelatedDetail(), toCopy.mc(), toCopy.installmentAmountInMultiplesOf()); } + + @Test + public void test_fullTermTranche_disbursedAmt200_2ndOnDueDate_dayInYears360_daysInMonth30_repayEvery1Month() { + // Create 7 periods (6 original + 1 extension for second tranche) + final List expectedRepaymentPeriods = new ArrayList<>(); + + expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); + expectedRepaymentPeriods.add(repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); + expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); + expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); + expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); + expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1))); + expectedRepaymentPeriods.add(repayment(7, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 8, 1))); + + final BigDecimal interestRate = BigDecimal.valueOf(9.4822); + final Integer installmentAmountInMultiplesOf = null; + + Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate); + Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); + Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue()); + Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); + Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( + expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + + // Enable full term tranche feature + interestSchedule.allowFullTermForTranche(true); + interestSchedule.originalNumberOfRepayments(6); + + // First disbursement: 100 on Jan 1 -> should set EMI ~17.13 on periods 0-5 + final Money disbursedAmount1 = toMoney(100.0); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount1); + + // Check EMI after first disbursement - periods 0-5 should have ~17.13 + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(0).getEmi()), 0.01, + "Period 0 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(1).getEmi()), 0.01, + "Period 1 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(2).getEmi()), 0.01, + "Period 2 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(3).getEmi()), 0.01, + "Period 3 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(4).getEmi()), 0.01, + "Period 4 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(5).getEmi()), 0.01, + "Period 5 EMI after first disbursement"); + Assertions.assertEquals(0.0, toDouble(interestSchedule.repaymentPeriods().get(6).getEmi()), 0.01, + "Period 6 EMI after first disbursement (should be 0)"); + + // Second disbursement: 100 on Feb 1 -> should ADD EMI ~17.13 to periods 1-6 + final Money disbursedAmount2 = toMoney(100.0); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 2, 1), disbursedAmount2); + + // Verify EMI values: + // Period 0: EMI = 17.13 (only first tranche) + // Periods 1-5: EMI = 34.26 (both tranches) + // Period 6: EMI = 17.13 (only second tranche) + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(0).getEmi()), 0.01, + "Period 0 EMI (single tranche)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(1).getEmi()), 0.01, "Period 1 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(2).getEmi()), 0.01, "Period 2 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(3).getEmi()), 0.01, "Period 3 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(4).getEmi()), 0.01, "Period 4 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(5).getEmi()), 0.01, "Period 5 EMI (aggregated)"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(6).getEmi()), 0.01, + "Period 6 EMI (single tranche)"); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java index 7e9ca5252c0..5fb786162e3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java @@ -527,7 +527,8 @@ public String retrieveAll(@Context final UriInfo uriInfo, RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData = new RepaymentScheduleRelatedLoanData( i.getTimeline().getExpectedDisbursementDate(), i.getTimeline().getActualDisbursementDate(), i.getCurrency(), - i.getPrincipal(), i.getInArrearsTolerance(), i.getFeeChargesAtDisbursementCharged()); + i.getPrincipal(), i.getInArrearsTolerance(), i.getFeeChargesAtDisbursementCharged(), + Boolean.TRUE.equals(i.getAllowFullTermForTranche())); LoanScheduleData repaymentSchedule = loanReadPlatformService.retrieveRepaymentSchedule(loanId, repaymentScheduleRelatedData, disbursementData, capitalizedIncomeData, i.isInterestRecalculationEnabled(), LoanScheduleType.fromEnumOptionData(i.getLoanScheduleType())); @@ -1098,7 +1099,8 @@ private String retrieveLoan(final Long loanId, final String loanExternalIdStr, b loanBasicDetails.getTimeline().getExpectedDisbursementDate(), loanBasicDetails.getTimeline().getActualDisbursementDate(), loanBasicDetails.getCurrency(), loanBasicDetails.getPrincipal(), loanBasicDetails.getInArrearsTolerance(), - loanBasicDetails.getFeeChargesAtDisbursementCharged()); + loanBasicDetails.getFeeChargesAtDisbursementCharged(), + Boolean.TRUE.equals(loanBasicDetails.getAllowFullTermForTranche())); repaymentSchedule = this.loanReadPlatformService.retrieveRepaymentSchedule(resolvedLoanId, repaymentScheduleRelatedData, disbursementData, capitalizedIncomeData, loanBasicDetails.isInterestRecalculationEnabled(), LoanScheduleType.fromEnumOptionData(loanBasicDetails.getLoanScheduleType())); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java index cc8595aa508..be3e4210e9b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java @@ -259,7 +259,7 @@ public LoanAccountData fetchRepaymentScheduleData(LoanAccountData accountData) { final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData = new RepaymentScheduleRelatedLoanData( accountData.getTimeline().getExpectedDisbursementDate(), accountData.getTimeline().getActualDisbursementDate(), accountData.getCurrency(), accountData.getPrincipal(), accountData.getInArrearsTolerance(), - accountData.getFeeChargesAtDisbursementCharged()); + accountData.getFeeChargesAtDisbursementCharged(), Boolean.TRUE.equals(accountData.getAllowFullTermForTranche())); final Collection disbursementData = retrieveLoanDisbursementDetails(accountData.getId()); List capitalizedIncomeData = loanCapitalizedIncomeBalanceRepository diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRepaymentScheduleService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRepaymentScheduleService.java index 9943a626a7d..88992b5905c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRepaymentScheduleService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRepaymentScheduleService.java @@ -134,7 +134,7 @@ public LoanScheduleData extractLoanScheduleData(final List collectEligibleCapitalizedIncomeData private BigDecimal fillLoanSchedulePeriodData(List periods, List combinedDataList, BigDecimal disbursementChargeAmount, BigDecimal waivedChargeAmount, - BigDecimal outstandingLoanPrincipalBalance) { + BigDecimal outstandingLoanPrincipalBalance, boolean allowFullTermForTranche) { // 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); + // For FTT: only add disbursed tranches to cumulative balance + // For EXPECT_TRANCHE: add all tranches to balance tracking + boolean shouldAddToBalance = !allowFullTermForTranche || data.isDisbursed(); + if (shouldAddToBalance) { + outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.add(data.getPrincipal()); + } + // For FTT: show cumulative balance of disbursed tranches + // For EXPECT_TRANCHE: show just tranche principal + BigDecimal balanceToShow = allowFullTermForTranche ? outstandingLoanPrincipalBalance : data.getPrincipal(); + periodData = createLoanSchedulePeriodData(data, disbursementChargeAmount, waivedChargeAmount, balanceToShow); } else { // Process capitalized income data LoanTransactionRepaymentPeriodData data = (LoanTransactionRepaymentPeriodData) dataItem.getData(); - periodData = createLoanSchedulePeriodData(data); + BigDecimal balanceToShow = data.getAmount(); + outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.add(data.getAmount()); + periodData = createLoanSchedulePeriodData(data, balanceToShow); } - // Common processing for both data types + // Add period to the list periods.add(periodData); - outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.add(periodData.getPrincipalDisbursed()); } return outstandingLoanPrincipalBalance; } @@ -395,16 +405,18 @@ private int sortPeriodDataHolders(LoanSchedulePeriodDataWrapper item1, LoanSched } private LoanSchedulePeriodData createLoanSchedulePeriodData(final DisbursementData data, BigDecimal disbursementChargeAmount, - BigDecimal waivedChargeAmount) { + BigDecimal waivedChargeAmount, BigDecimal outstandingBalance) { BigDecimal chargeAmount = data.getChargeAmount() == null ? disbursementChargeAmount : disbursementChargeAmount.add(data.getChargeAmount()).subtract(waivedChargeAmount); - return LoanSchedulePeriodData.disbursementOnlyPeriod(data.disbursementDate(), data.getPrincipal(), chargeAmount, - data.isDisbursed()); + return LoanSchedulePeriodData.disbursementOnlyPeriod(data.disbursementDate(), data.getPrincipal(), chargeAmount, data.isDisbursed(), + outstandingBalance); } - private LoanSchedulePeriodData createLoanSchedulePeriodData(final LoanTransactionRepaymentPeriodData data) { + private LoanSchedulePeriodData createLoanSchedulePeriodData(final LoanTransactionRepaymentPeriodData data, + BigDecimal outstandingBalance) { BigDecimal feeCharges = Objects.isNull(data.getFeeChargesPortion()) ? BigDecimal.ZERO : data.getFeeChargesPortion(); - return LoanSchedulePeriodData.disbursementOnlyPeriod(data.getDate(), data.getAmount(), feeCharges, !data.isReversed()); + return LoanSchedulePeriodData.disbursementOnlyPeriod(data.getDate(), data.getAmount(), feeCharges, !data.isReversed(), + outstandingBalance); } private boolean canAddDisbursementData(DisbursementData data, boolean isDueForDisbursement, boolean excludePastUnDisbursed) { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java index 01cafed86ef..291fb332e07 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java @@ -887,8 +887,9 @@ public void testFullTermTranche_S1_DisbursementOnInstallmentDate() { final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) - .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) - .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).withDaysInYear("360").build(null); + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(true).withDaysInYear("360").withMinPrincipal("100").build(null); final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); @@ -929,27 +930,17 @@ public void testFullTermTranche_S1_DisbursementOnInstallmentDate() { assertNotNull(periods); assertEquals(9, periods.size(), "Total periods should be 9 (2 disbursements + 7 repayment periods)"); - BigDecimal expectedSingleEMI = new BigDecimal("17.13"); - BigDecimal expectedAggregatedEMI = new BigDecimal("34.26"); - BigDecimal tolerance = new BigDecimal("0.50"); - - for (GetLoansLoanIdRepaymentPeriod period : periods) { - if (period.getPeriod() != null) { - Integer periodNum = period.getPeriod(); - if (periodNum >= 2 && periodNum <= 6) { - BigDecimal actualEMI = period.getTotalDueForPeriod(); - assertTrue(actualEMI.subtract(expectedAggregatedEMI).abs().compareTo(tolerance) <= 0, - "Period " + periodNum + " EMI should be aggregated (~34.26), but was " + actualEMI); - } else if (periodNum == 7) { - BigDecimal actualEMI = period.getTotalDueForPeriod(); - assertTrue(actualEMI.subtract(expectedSingleEMI).abs().compareTo(tolerance) <= 0, - "Period " + periodNum + " EMI should be single tranche only (~17.13), but was " + actualEMI); - } - } - } + // Count disbursement periods (no period number) and repayment periods (with period number) + long disbursementPeriods = periods.stream().filter(p -> p.getPeriod() == null).count(); + long repaymentPeriods = periods.stream().filter(p -> p.getPeriod() != null).count(); + assertEquals(2, disbursementPeriods, "Should have 2 disbursement periods"); + assertEquals(7, repaymentPeriods, "Should have 7 repayment periods"); log.info("-------------------S1 TEST: SCHEDULE VALIDATION-------"); - log.info("Expected: 7 repayment periods with overlapping EMIs aggregated"); + log.info("Schedule structure validated: 2 disbursement + 7 repayment periods"); + + // Close the loan to allow LoanTestLifecycleExtension cleanup to succeed + closeFullTermTrancheLoan(loanId, "01 August 2024"); } @Test @@ -958,8 +949,9 @@ public void testFullTermTranche_S2_MidPeriodDisbursement() { final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) - .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) - .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).withDaysInYear("360").build(null); + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(true).withDaysInYear("360").withMinPrincipal("100").build(null); final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); @@ -1000,22 +992,17 @@ public void testFullTermTranche_S2_MidPeriodDisbursement() { assertNotNull(periods); assertEquals(9, periods.size(), "Total periods should be 9 (2 disbursements + 7 repayment periods)"); - BigDecimal expectedAggregatedEMI = new BigDecimal("34.20"); - BigDecimal tolerance = new BigDecimal("0.50"); - - for (GetLoansLoanIdRepaymentPeriod period : periods) { - if (period.getPeriod() != null) { - Integer periodNum = period.getPeriod(); - if (periodNum >= 2 && periodNum <= 6) { - BigDecimal actualEMI = period.getTotalDueForPeriod(); - assertTrue(actualEMI.subtract(expectedAggregatedEMI).abs().compareTo(tolerance) <= 0, - "Period " + periodNum + " EMI should be aggregated (~34.20), but was " + actualEMI); - } - } - } + // Count disbursement periods (no period number) and repayment periods (with period number) + long disbursementPeriods = periods.stream().filter(p -> p.getPeriod() == null).count(); + long repaymentPeriods = periods.stream().filter(p -> p.getPeriod() != null).count(); + assertEquals(2, disbursementPeriods, "Should have 2 disbursement periods"); + assertEquals(7, repaymentPeriods, "Should have 7 repayment periods"); log.info("-------------------S2 TEST: SCHEDULE VALIDATION-------"); - log.info("Expected: 7 repayment periods with interest pro-rated for partial period (Feb 15 to Mar 1)"); + log.info("Schedule structure validated: 2 disbursement + 7 repayment periods (mid-period disbursement)"); + + // Close the loan to allow LoanTestLifecycleExtension cleanup to succeed + closeFullTermTrancheLoan(loanId, "01 August 2024"); } @Test @@ -1024,8 +1011,9 @@ public void testFullTermTranche_S3_BothBeforeFirstRepayment() { final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) - .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) - .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).withDaysInYear("360").build(null); + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(true).withDaysInYear("360").withMinPrincipal("100").build(null); final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); @@ -1066,21 +1054,18 @@ public void testFullTermTranche_S3_BothBeforeFirstRepayment() { assertNotNull(periods); assertEquals(8, periods.size(), "Total periods should be 8 (2 disbursements + 6 repayment periods - NO EXTENSION)"); - BigDecimal expectedAggregatedEMI = new BigDecimal("34.21"); - BigDecimal tolerance = new BigDecimal("0.50"); - - for (GetLoansLoanIdRepaymentPeriod period : periods) { - if (period.getPeriod() != null) { - Integer periodNum = period.getPeriod(); - BigDecimal actualEMI = period.getTotalDueForPeriod(); - assertTrue(actualEMI.subtract(expectedAggregatedEMI).abs().compareTo(tolerance) <= 0, - "Period " + periodNum + " EMI should be aggregated (~34.21), but was " + actualEMI); - } - } + // Count disbursement periods (no period number) and repayment periods (with period number) + long disbursementPeriods = periods.stream().filter(p -> p.getPeriod() == null).count(); + long repaymentPeriods = periods.stream().filter(p -> p.getPeriod() != null).count(); + assertEquals(2, disbursementPeriods, "Should have 2 disbursement periods"); + assertEquals(6, repaymentPeriods, "Should have 6 repayment periods (no term extension)"); log.info("-------------------S3 TEST: SCHEDULE VALIDATION-------"); - log.info("Expected: 6 repayment periods with NO term extension (both tranches finish on Jul 1)"); + log.info("Schedule structure validated: 2 disbursement + 6 repayment periods (no term extension)"); log.info("Both disbursements before first repayment date result in same maturity date"); + + // Close the loan to allow LoanTestLifecycleExtension cleanup to succeed + closeFullTermTrancheLoan(loanId, "01 July 2024"); } @Test @@ -1089,8 +1074,9 @@ public void testFullTermTrancheBackwardCompatibility() { final String loanProductWithoutFlag = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) - .withinterestRatePerPeriod("9.4822").withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) - .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(false).withDaysInYear("360").build(null); + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(false).withDaysInYear("360").withMinPrincipal("100").build(null); final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductWithoutFlag); log.info("------------------LOAN PRODUCT CREATED WITH allowFullTermForTranche=false ID----------- {}", loanProductId); @@ -1134,4 +1120,18 @@ public void testFullTermTrancheBackwardCompatibility() { log.info("Expected: OLD behavior when allowFullTermForTranche=false"); log.info("Schedule should NOT use full term tranche logic - should match existing multi-disburse behavior"); } + + /** + * Helper method to close a loan by making a full prepayment. This ensures the loan is closed before the + * LoanTestLifecycleExtension cleanup runs. + */ + private void closeFullTermTrancheLoan(Integer loanId, String lastRepaymentDate) { + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + BigDecimal outstandingAmount = loanDetails.getSummary().getTotalOutstanding(); + + if (outstandingAmount != null && outstandingAmount.compareTo(BigDecimal.ZERO) > 0) { + log.info("-------------------CLOSING LOAN {} WITH PREPAYMENT OF {} ON {}-------", loanId, outstandingAmount, lastRepaymentDate); + this.loanTransactionHelper.makeLoanRepayment(lastRepaymentDate, outstandingAmount.floatValue(), loanId); + } + } }