Skip to content

Commit 1c59ecb

Browse files
authored
Merge pull request #5729
FINERACT-2197: Fix FEB_29_PERIOD_ONLY strategy for cross-year repayment periods
2 parents a628437 + 4a5aeb3 commit 1c59ecb

File tree

2 files changed

+61
-5
lines changed

2 files changed

+61
-5
lines changed

fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,12 +1280,15 @@ private void calculateRateFactorForPeriods(final List<RepaymentPeriod> repayment
12801280
}
12811281

12821282
private boolean isPeriodContainsFeb29(final LocalDate repaymentPeriodFromDate, final LocalDate repaymentPeriodDueDate) {
1283-
if (repaymentPeriodFromDate.isLeapYear()) {
1284-
final LocalDate leapDay = LocalDate.of(repaymentPeriodFromDate.getYear(), 2, 29);
1285-
return DateUtils.isDateInRangeFromExclusiveToInclusive(leapDay, repaymentPeriodFromDate, repaymentPeriodDueDate);
1286-
} else {
1287-
return false;
1283+
for (int year = repaymentPeriodFromDate.getYear(); year <= repaymentPeriodDueDate.getYear(); year++) {
1284+
if (Year.isLeap(year)) {
1285+
final LocalDate leapDay = LocalDate.of(year, 2, 29);
1286+
if (DateUtils.isDateInRangeFromExclusiveToInclusive(leapDay, repaymentPeriodFromDate, repaymentPeriodDueDate)) {
1287+
return true;
1288+
}
1289+
}
12881290
}
1291+
return false;
12891292
}
12901293

12911294
private Integer numberOfDaysFeb29PeriodOnly(final LocalDate repaymentPeriodFromDate, final LocalDate repaymentPeriodDueDate) {

fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3174,6 +3174,59 @@ public void test_leap_year_only_actual_for_loan_S5() {
31743174
checkPeriod(interestSchedule, 5, 867.65, 10.03, 857.62, 0.00, false);
31753175
}
31763176

3177+
/**
3178+
* Cross-year edge case: quarterly repayment period spans from non-leap year (2023) into leap year (2024) and
3179+
* contains Feb 29. With FEB_29_PERIOD_ONLY strategy, this period should use 366 days (same as FULL_LEAP_YEAR)
3180+
* because Feb 29 falls within the period.
3181+
*/
3182+
@Test
3183+
public void test_feb29_period_only_cross_year_quarterly_period_containing_feb29() {
3184+
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = List.of(
3185+
periodData(LocalDate.of(2023, 9, 1), LocalDate.of(2023, 12, 1)),
3186+
periodData(LocalDate.of(2023, 12, 1), LocalDate.of(2024, 3, 1)),
3187+
periodData(LocalDate.of(2024, 3, 1), LocalDate.of(2024, 6, 1)),
3188+
periodData(LocalDate.of(2024, 6, 1), LocalDate.of(2024, 9, 1)));
3189+
3190+
final BigDecimal interestRate = BigDecimal.valueOf(12.0);
3191+
final Integer installmentAmountInMultiplesOf = null;
3192+
3193+
// Schedule with FEB_29_PERIOD_ONLY
3194+
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
3195+
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue());
3196+
Mockito.when(loanProductRelatedDetail.getDaysInYearCustomStrategy())
3197+
.thenReturn(DaysInYearCustomStrategyType.FEB_29_PERIOD_ONLY);
3198+
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue());
3199+
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
3200+
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(3);
3201+
Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency);
3202+
Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false);
3203+
3204+
final ProgressiveLoanInterestScheduleModel feb29Schedule = emiCalculator.generatePeriodInterestScheduleModel(
3205+
expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
3206+
emiCalculator.addDisbursement(feb29Schedule, LocalDate.of(2023, 9, 1), toMoney(10000.0));
3207+
3208+
// Schedule with FULL_LEAP_YEAR
3209+
Mockito.when(loanProductRelatedDetail.getDaysInYearCustomStrategy()).thenReturn(DaysInYearCustomStrategyType.FULL_LEAP_YEAR);
3210+
3211+
final ProgressiveLoanInterestScheduleModel fullLeapSchedule = emiCalculator.generatePeriodInterestScheduleModel(
3212+
expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
3213+
emiCalculator.addDisbursement(fullLeapSchedule, LocalDate.of(2023, 9, 1), toMoney(10000.0));
3214+
3215+
// Period 1 (Dec 1, 2023 → Mar 1, 2024) contains Feb 29, 2024.
3216+
// Both strategies should use 366 days for this period, so interest should match.
3217+
final RepaymentPeriod feb29Period1 = feb29Schedule.repaymentPeriods().get(1);
3218+
final RepaymentPeriod fullLeapPeriod1 = fullLeapSchedule.repaymentPeriods().get(1);
3219+
Assertions.assertEquals(toDouble(fullLeapPeriod1.getDueInterest()), toDouble(feb29Period1.getDueInterest()),
3220+
"Cross-year period containing Feb 29 should use 366 days for both strategies");
3221+
3222+
// Period 2 (Mar 1, 2024 → Jun 1, 2024) does NOT contain Feb 29.
3223+
// FEB_29_PERIOD_ONLY should use 365 days, FULL_LEAP_YEAR should use 366 days.
3224+
final RepaymentPeriod feb29Period2 = feb29Schedule.repaymentPeriods().get(2);
3225+
final RepaymentPeriod fullLeapPeriod2 = fullLeapSchedule.repaymentPeriods().get(2);
3226+
Assertions.assertNotEquals(toDouble(fullLeapPeriod2.getDueInterest()), toDouble(feb29Period2.getDueInterest()),
3227+
"Period without Feb 29 should differ between strategies (365 vs 366 days)");
3228+
}
3229+
31773230
@Test
31783231
public void test_leap_year_only_actual_no_effect_on_360_loan() {
31793232
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = List.of(

0 commit comments

Comments
 (0)