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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1280,12 +1280,15 @@ private void calculateRateFactorForPeriods(final List<RepaymentPeriod> repayment
}

private boolean isPeriodContainsFeb29(final LocalDate repaymentPeriodFromDate, final LocalDate repaymentPeriodDueDate) {
if (repaymentPeriodFromDate.isLeapYear()) {
final LocalDate leapDay = LocalDate.of(repaymentPeriodFromDate.getYear(), 2, 29);
return DateUtils.isDateInRangeFromExclusiveToInclusive(leapDay, repaymentPeriodFromDate, repaymentPeriodDueDate);
} else {
return false;
for (int year = repaymentPeriodFromDate.getYear(); year <= repaymentPeriodDueDate.getYear(); year++) {
if (Year.isLeap(year)) {
final LocalDate leapDay = LocalDate.of(year, 2, 29);
if (DateUtils.isDateInRangeFromExclusiveToInclusive(leapDay, repaymentPeriodFromDate, repaymentPeriodDueDate)) {
return true;
}
}
}
return false;
}

private Integer numberOfDaysFeb29PeriodOnly(final LocalDate repaymentPeriodFromDate, final LocalDate repaymentPeriodDueDate) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3174,6 +3174,59 @@ public void test_leap_year_only_actual_for_loan_S5() {
checkPeriod(interestSchedule, 5, 867.65, 10.03, 857.62, 0.00, false);
}

/**
* Cross-year edge case: quarterly repayment period spans from non-leap year (2023) into leap year (2024) and
* contains Feb 29. With FEB_29_PERIOD_ONLY strategy, this period should use 366 days (same as FULL_LEAP_YEAR)
* because Feb 29 falls within the period.
*/
@Test
public void test_feb29_period_only_cross_year_quarterly_period_containing_feb29() {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = List.of(
periodData(LocalDate.of(2023, 9, 1), LocalDate.of(2023, 12, 1)),
periodData(LocalDate.of(2023, 12, 1), LocalDate.of(2024, 3, 1)),
periodData(LocalDate.of(2024, 3, 1), LocalDate.of(2024, 6, 1)),
periodData(LocalDate.of(2024, 6, 1), LocalDate.of(2024, 9, 1)));

final BigDecimal interestRate = BigDecimal.valueOf(12.0);
final Integer installmentAmountInMultiplesOf = null;

// Schedule with FEB_29_PERIOD_ONLY
Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInYearCustomStrategy())
.thenReturn(DaysInYearCustomStrategyType.FEB_29_PERIOD_ONLY);
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(3);
Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency);
Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false);

final ProgressiveLoanInterestScheduleModel feb29Schedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(feb29Schedule, LocalDate.of(2023, 9, 1), toMoney(10000.0));

// Schedule with FULL_LEAP_YEAR
Mockito.when(loanProductRelatedDetail.getDaysInYearCustomStrategy()).thenReturn(DaysInYearCustomStrategyType.FULL_LEAP_YEAR);

final ProgressiveLoanInterestScheduleModel fullLeapSchedule = emiCalculator.generatePeriodInterestScheduleModel(
expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc);
emiCalculator.addDisbursement(fullLeapSchedule, LocalDate.of(2023, 9, 1), toMoney(10000.0));

// Period 1 (Dec 1, 2023 → Mar 1, 2024) contains Feb 29, 2024.
// Both strategies should use 366 days for this period, so interest should match.
final RepaymentPeriod feb29Period1 = feb29Schedule.repaymentPeriods().get(1);
final RepaymentPeriod fullLeapPeriod1 = fullLeapSchedule.repaymentPeriods().get(1);
Assertions.assertEquals(toDouble(fullLeapPeriod1.getDueInterest()), toDouble(feb29Period1.getDueInterest()),
"Cross-year period containing Feb 29 should use 366 days for both strategies");

// Period 2 (Mar 1, 2024 → Jun 1, 2024) does NOT contain Feb 29.
// FEB_29_PERIOD_ONLY should use 365 days, FULL_LEAP_YEAR should use 366 days.
final RepaymentPeriod feb29Period2 = feb29Schedule.repaymentPeriods().get(2);
final RepaymentPeriod fullLeapPeriod2 = fullLeapSchedule.repaymentPeriods().get(2);
Assertions.assertNotEquals(toDouble(fullLeapPeriod2.getDueInterest()), toDouble(feb29Period2.getDueInterest()),
"Period without Feb 29 should differ between strategies (365 vs 366 days)");
}

@Test
public void test_leap_year_only_actual_no_effect_on_360_loan() {
final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = List.of(
Expand Down
Loading