Ralph Hopman created FINERACT-2492:
--------------------------------------
Summary: AprCalculator produces incorrect annual interest rate
when DaysInYearType is ACTUAL with WHOLE_TERM frequency
Key: FINERACT-2492
URL: https://issues.apache.org/jira/browse/FINERACT-2492
Project: Apache Fineract
Issue Type: Bug
Components: Loan
Reporter: Ralph Hopman
Assignee: Ralph Hopman
When calculating annual nominal interest rates for loans using
{{interestRateFrequencyType = WHOLE_TERM}} combined with {{{}daysInYearType =
ACTUAL{}}}, the {{AprCalculator}} produces dramatically incorrect results. The
calculator multiplies the per-period rate by {{daysInYearType.getValue()}}
which returns {{1}} for ACTUAL instead of {{{}365{}}}, resulting in interest
charges that are ~365x lower than expected.
h2. Steps to Reproduce
# Create a loan product with:
** {{daysInYearType = ACTUAL}}
** {{interestType = FLAT}}
** {{interestRateFrequencyType = WHOLE_TERM}}
# Create a loan with:
** {{principal = 3,400}}
** {{interestRatePerPeriod = 10}} (expecting 10% total interest = 340)
** {{numberOfRepayments = 3}}
** {{repaymentEvery = 1}}
** {{repaymentFrequencyType = DAYS}}
** {{loanTermFrequency = 3}}
** {{loanTermFrequencyType = DAYS}}
# Calculate loan schedule via {{POST /loans?command=calculateLoanSchedule}}
h2. Expected Behavior
*Annual rate calculation:*
{code:java}
ratePerPeriod = 10% ÷ 3 repayments = 3.333%
annualRate = 3.333% × 365 days = 1,216.67%
{code}
*Interest for 3-day term:*
{code:java}
interestRate = (1,216.67% / 365 / 100) × 3 days = 10%
totalInterest = 3,400 × 10% = 340.00
{code}
*Result:* Each of 3 installments should have *113.33* interest charge.
h2. Actual Behavior
*Annual rate calculation:*
{code:java}
ratePerPeriod = 10% ÷ 3 repayments = 3.333%
annualRate = 3.333% × 1 = 3.333% ← WRONG!
{code}
*Interest for 3-day term:*
{code:java}
interestRate = (3.333% / 365 / 100) × 3 days = 0.0274%
totalInterest = 3,400 × 0.0274% = 0.93
{code}
*Result:* Total interest charged is only *0.93* instead of *340.00* (365x too
low).
h2. Root Cause
In {{{}AprCalculator.java{}}}, both the {{DAYS}} case (line 40) and the
{{WHOLE_TERM → DAYS}} case (line 56) use {{daysInYearType.getValue()}} directly:
{code:java}
case DAYS:
defaultAnnualNominalInterestRate = interestRatePerPeriod
.multiply(BigDecimal.valueOf(daysInYearType.getValue())); //
getValue() returns 1 for ACTUAL!
break;
{code}
The {{DaysInYearType}} enum defines:
{code:java}
ACTUAL(1, "DaysInYearType.actual"),
DAYS_360(360, "DaysInYearType.days360"),
DAYS_364(364, "DaysInYearType.days364"),
DAYS_365(365, "DaysInYearType.days365");
{code}
The value {{1}} for ACTUAL is a sentinel value intended to signal "use actual
days calculation" (handled properly in other parts of the codebase like
{{{}LoanApplicationTerms.calculatePeriodsInOneYear(){}}}). However, in
{{{}AprCalculator{}}}, this sentinel value is incorrectly used directly as a
multiplier for annualization.
The correct pattern (used elsewhere in Fineract) is to delegate to
{{PaymentPeriodsInOneYearCalculator}} when {{{}daysInYearType == ACTUAL{}}},
rather than using the enum's {{getValue()}} directly.
h2. Solution
Inject {{PaymentPeriodsInOneYearCalculator}} into {{AprCalculator}} and add a
helper method {{getDaysInYear()}} that delegates to the calculator when
{{{}daysInYearType == ACTUAL{}}}:
{code:java}
@Component
@RequiredArgsConstructor
public class AprCalculator {
private final PaymentPeriodsInOneYearCalculator
paymentPeriodsInOneYearCalculator;
public BigDecimal calculateFrom(..., final DaysInYearType daysInYearType) {
// ... existing code ...
case DAYS:
defaultAnnualNominalInterestRate = interestRatePerPeriod
.multiply(BigDecimal.valueOf(getDaysInYear(daysInYearType)));
break;
// ... similar for WHOLE_TERM → DAYS case ...
}
/**
* Helper method to get the number of days in a year, handling ACTUAL
appropriately.
*
* When daysInYearType is ACTUAL, this delegates to the
PaymentPeriodsInOneYearCalculator
* (consistent with how Fineract handles ACTUAL elsewhere). For other types
(DAYS_360,
* DAYS_364, DAYS_365), it returns the configured value.
*/
private int getDaysInYear(final DaysInYearType daysInYearType) {
// When ACTUAL, delegate to calculator (consistent with
LoanApplicationTerms.calculatePeriodsInOneYear)
if (daysInYearType == DaysInYearType.ACTUAL) {
return
paymentPeriodsInOneYearCalculator.calculate(PeriodFrequencyType.DAYS);
}
// For DAYS_360, DAYS_364, DAYS_365: use configured value
return daysInYearType.getValue();
}
}
{code}
This approach:
* Delegates to
{{PaymentPeriodsInOneYearCalculator.calculate(PeriodFrequencyType.DAYS)}} which
returns {{365}}
* Follows the established pattern used in
{{LoanApplicationTerms.calculatePeriodsInOneYear()}}
* Maintains architectural consistency across the codebase
* Automatically benefits from any future enhancements to the calculator (e.g.,
leap year support)
h2. Testing Recommendations
# Add unit tests for {{AprCalculator.calculateFrom()}} covering:
** {{DAYS}} frequency with {{DaysInYearType.ACTUAL}}
** {{WHOLE_TERM}} frequency with {{DAYS}} repayment and
{{DaysInYearType.ACTUAL}}
** Verify {{{}DAYS_360{}}}, {{{}DAYS_364{}}}, {{DAYS_365}} continue to work
correctly
** Mock {{PaymentPeriodsInOneYearCalculator}} to verify delegation behavior
# Add integration test for loan schedule calculation with the reproduction
steps above
# Verify existing loans with {{DaysInYearType.ACTUAL}} produce correct
interest after fix
--
This message was sent by Atlassian Jira
(v8.20.10#820010)