FINERACT-2326: Improve null-safety

This commit is contained in:
Adam Saghy
2025-08-13 11:02:22 +02:00
parent 53d9790c8c
commit c2519c77c7
6 changed files with 468 additions and 128 deletions
@@ -335,6 +335,10 @@ public final class MathUtil {
return nullToDefault(value, Money.zero(currency));
}
public static Money nullToZero(Money value, @NotNull MonetaryCurrency currency, @NotNull MathContext mc) {
return nullToDefault(value, Money.zero(currency, mc));
}
public static Money nullToDefault(Money value, Money def) {
return value == null ? def : value;
}
@@ -125,8 +125,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
}
private void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
scheduleModel.repaymentPeriods().stream().filter(rp -> !operation.getSubmittedOnDate().isAfter(rp.getFromDate())).forEach(
rp -> rp.setTotalDisbursedAmount(MathUtil.nullToZero(rp.getTotalDisbursedAmount()).add(operation.getAmount().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(),
@@ -162,8 +162,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
private void addCapitalizedIncome(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
scheduleModel.repaymentPeriods().stream().filter(rp -> !operation.getSubmittedOnDate().isAfter(rp.getFromDate()))
.forEach(rp -> rp.setTotalCapitalizedIncomeAmount(
MathUtil.nullToZero(rp.getTotalCapitalizedIncomeAmount()).add(operation.getAmount().getAmount())));
.forEach(rp -> rp.setTotalCapitalizedIncomeAmount(rp.getTotalCapitalizedIncomeAmount().plus(operation.getAmount())));
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), scheduleModel.zero(),
scheduleModel.zero(), operation.getAmount()).ifPresent((repaymentPeriod) -> {
@@ -225,7 +224,6 @@ public final class ProgressiveEMICalculator implements EMICalculator {
return;
}
Optional<RepaymentPeriod> repaymentPeriod = findRepaymentPeriod(scheduleModel, repaymentPeriodDueDate);
boolean transactionDateIsBefore = transactionDate.isBefore(repaymentPeriod.get().getFromDate());
repaymentPeriod.ifPresent(rp -> rp.addPaidPrincipalAmount(principalAmount));
// If it is paid late, we need to calculate with the period due date
LocalDate balanceCorrectionDate = DateUtils.isBefore(repaymentPeriodDueDate, transactionDate) ? repaymentPeriodDueDate
@@ -235,6 +233,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
repaymentPeriod.ifPresent(rp -> {
// If any period total paid > calculated EMI, then set EMI to total paid -> effectively it is marked as
// fully paid
boolean transactionDateIsBefore = transactionDate.isBefore(repaymentPeriod.get().getFromDate());
if (transactionDateIsBefore
&& rp.getTotalPaidAmount().isGreaterThan(rp.getEmiPlusCreditedAmountsPlusFutureUnrecognizedInterest())) {
rp.setEmi(rp.getTotalPaidAmount().minus(rp.getTotalCreditedAmount()));
@@ -368,7 +367,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
return recalculatedScheduleModelTillDate.getLastRepaymentPeriod();
} else {
// if target date is before 1st disbursement date, we use 1st repayment period
return recalculatedScheduleModelTillDate.repaymentPeriods().get(0);
return recalculatedScheduleModelTillDate.repaymentPeriods().getFirst();
}
});
@@ -406,7 +405,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
@NotNull ProgressiveLoanInterestScheduleModel scheduleModel, @NotNull LocalDate targetDate) {
MathContext mc = scheduleModel.mc();
ProgressiveLoanInterestScheduleModel scheduleModelCopy = scheduleModel.deepCopy(mc);
boolean isBeforeFirstDisbursement = targetDate.isBefore(scheduleModelCopy.repaymentPeriods().get(0).getFromDate());
boolean isBeforeFirstDisbursement = targetDate.isBefore(scheduleModelCopy.repaymentPeriods().getFirst().getFromDate());
boolean isAfterMaturityDate = !targetDate.isBefore(scheduleModelCopy.getLastRepaymentPeriod().getDueDate());
if (isBeforeFirstDisbursement) {
scheduleModelCopy.repaymentPeriods().forEach(rp -> rp.getInterestPeriods().clear());
@@ -436,13 +435,13 @@ public final class ProgressiveEMICalculator implements EMICalculator {
.subList(nextIdx, ip.getRepaymentPeriod().getInterestPeriods().size()).clear();
});
} else if (rp.getPrevious().isPresent() && rp.getPrevious().get().equals(repaymentPeriod)
&& (rp.getInterestPeriods().get(0).getCreditedInterest().isGreaterThanZero()
|| rp.getInterestPeriods().get(0).getCreditedPrincipal().isGreaterThanZero())) {
&& (rp.getInterestPeriods().getFirst().getCreditedInterest().isGreaterThanZero()
|| rp.getInterestPeriods().getFirst().getCreditedPrincipal().isGreaterThanZero())) {
// NOTE: we need to check whether there is credited on the 1st interest period of the next
// period
// if so, we need to retain that interest period, but need to update due date to match with from
// date -> 0 interest
rp.getInterestPeriods().get(0).setDueDate(rp.getInterestPeriods().get(0).getFromDate());
rp.getInterestPeriods().getFirst().setDueDate(rp.getInterestPeriods().getFirst().getFromDate());
if (rp.getInterestPeriods().size() > 1) {
rp.getInterestPeriods().subList(1, rp.getInterestPeriods().size()).clear();
}
@@ -580,7 +579,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
if (newScheduleModel == null) {
newScheduleModel = scheduleModel.deepCopy(mc);
}
final LocalDate relatedPeriodsFirstDueDate = relatedRepaymentPeriods.get(0).getDueDate();
final LocalDate relatedPeriodsFirstDueDate = relatedRepaymentPeriods.getFirst().getDueDate();
newScheduleModel.repaymentPeriods().forEach(period -> {
if (!period.getDueDate().isBefore(relatedPeriodsFirstDueDate)
&& !adjustedEqualMonthlyInstallmentValue.isLessThan(period.getTotalPaidAmount())) {
@@ -913,8 +912,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
final MathContext mc = scheduleModel.mc();
final CurrencyData currency = scheduleModel.loanProductRelatedDetail().getCurrencyData();
RepaymentPeriod first = repaymentPeriods.getFirst();
RepaymentPeriod last = repaymentPeriods.getLast();
RepaymentPeriod firstRepaymentPeriod = repaymentPeriods.getFirst();
RepaymentPeriod lastRepaymentPeriod = repaymentPeriods.getLast();
Money sumOfInterest = Money.zero(currency);
for (RepaymentPeriod rp : repaymentPeriods) {
Money interest = rp.calculateCalculatedDueInterest();
@@ -923,12 +922,11 @@ public final class ProgressiveEMICalculator implements EMICalculator {
}
// already repaid principals should be subtracted from total disbursed amount to calculate correct EMI.
BigDecimal alreadyRepaidPrincipals = first.getPrevious()
.map(rp -> rp.calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(null)
.subtract(rp.getOutstandingLoanBalance().getAmount()))
.orElse(BigDecimal.ZERO);
Money total = Money
.of(currency, first.calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(first.getLastInterestPeriod()))
Money alreadyRepaidPrincipals = firstRepaymentPeriod.getPrevious()
.map(rp -> rp.calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(null).minus(rp.getOutstandingLoanBalance()))
.orElse(null);
Money total = firstRepaymentPeriod
.calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(firstRepaymentPeriod.getLastInterestPeriod())
.plus(sumOfInterest).minus(alreadyRepaidPrincipals);
Money periodEmi = total.dividedBy(repaymentPeriods.size(), mc);
@@ -936,7 +934,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
Money remainder = total.minus(periodEmiInMultiplesOf.multipliedBy(repaymentPeriods.size(), mc));
repaymentPeriods.forEach(rp -> {
Money emi = rp.equals(last) ? periodEmiInMultiplesOf.add(remainder) : periodEmiInMultiplesOf;
Money emi = rp.equals(lastRepaymentPeriod) ? periodEmiInMultiplesOf.add(remainder) : periodEmiInMultiplesOf;
rp.setEmi(emi);
rp.setOriginalEmi(emi);
rp.getInterestPeriods().forEach(InterestPeriod::updateOutstandingLoanBalance);
@@ -959,7 +957,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
final MathContext mc = scheduleModel.mc();
final BigDecimal rateFactorN = MathUtil.stripTrailingZeros(calculateRateFactorPlus1N(repaymentPeriods, mc));
final BigDecimal fnResult = MathUtil.stripTrailingZeros(calculateFnResult(repaymentPeriods, mc));
final RepaymentPeriod startPeriod = repaymentPeriods.get(0);
final RepaymentPeriod startPeriod = repaymentPeriods.getFirst();
final Money outstandingBalance = startPeriod.getInitialBalanceForEmiRecalculation();
@@ -985,7 +983,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
addDisbursement(scheduleModelCopy, operation.withZeroAmount());
addCapitalizedIncome(scheduleModelCopy, operation.withZeroAmount());
final LocalDate firstDueDate = repaymentPeriods.get(0).getDueDate();
final LocalDate firstDueDate = repaymentPeriods.getFirst().getDueDate();
scheduleModel.copyPeriodsFrom(firstDueDate, scheduleModelCopy.repaymentPeriods(), (newRepaymentPeriod, actualRepaymentPeriod) -> {
actualRepaymentPeriod.setEmi(newRepaymentPeriod.getEmi());
actualRepaymentPeriod.setOriginalEmi(newRepaymentPeriod.getOriginalEmi());
@@ -1009,7 +1007,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
getUncountablePeriods(repaymentPeriods, penultimatePeriod.getEmi()));
}
}
return new EmiAdjustment(repaymentPeriods.get(0).getEmi(), repaymentPeriods.get(0).getEmi().copy(0.0), repaymentPeriods, 0);
return new EmiAdjustment(repaymentPeriods.getFirst().getEmi(), repaymentPeriods.getFirst().getEmi().copy(0.0), repaymentPeriods, 0);
}
private void calculateRateFactorForScheduleTillDateInclusive(ProgressiveLoanInterestScheduleModel scheduleModelCopy,
@@ -32,13 +32,14 @@ import lombok.ToString;
import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
@Getter
@ToString(exclude = { "repaymentPeriod" })
@EqualsAndHashCode(exclude = { "repaymentPeriod" })
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class InterestPeriod implements Comparable<InterestPeriod> {
@JsonExclude
@@ -49,6 +50,7 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
@Setter
@NotNull
private LocalDate dueDate;
@Setter
private BigDecimal rateFactor;
@Setter
@@ -58,15 +60,14 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
private Money creditedPrincipal;
/** Stores credited interest. Related transaction: Chargeback */
private Money creditedInterest;
private Money disbursementAmount;
private Money balanceCorrectionAmount;
private Money outstandingLoanBalance;
private Money capitalizedIncomePrincipal;
@JsonExclude
@Getter
private final MathContext mc;
private final boolean isPaused;
public static InterestPeriod copy(@NotNull RepaymentPeriod repaymentPeriod, @NotNull InterestPeriod interestPeriod, MathContext mc) {
@@ -90,59 +91,60 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
public static InterestPeriod withEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate,
LocalDate dueDate) {
final Money zero = repaymentPeriod.getEmi().zero();
final Money zero = repaymentPeriod.getZero();
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero, zero,
zero.getMc(), false);
}
public static InterestPeriod withEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate, LocalDate dueDate,
boolean isPaused) {
final Money zero = repaymentPeriod.getEmi().zero();
final Money zero = repaymentPeriod.getZero();
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero, zero,
zero.getMc(), isPaused);
}
public static InterestPeriod withPausedAndEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate,
LocalDate dueDate) {
final Money zero = repaymentPeriod.getEmi().zero();
final Money zero = repaymentPeriod.getZero();
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero, zero,
zero.getMc(), true);
}
@Override
public int compareTo(@NotNull InterestPeriod o) {
return dueDate.compareTo(o.dueDate);
return getDueDate().compareTo(o.getDueDate());
}
public void addBalanceCorrectionAmount(final Money balanceCorrectionAmount) {
this.balanceCorrectionAmount = MathUtil.plus(this.balanceCorrectionAmount, balanceCorrectionAmount);
public void addBalanceCorrectionAmount(final Money additionalBalanceCorrectionAmount) {
this.balanceCorrectionAmount = MathUtil.plus(this.getBalanceCorrectionAmount(), additionalBalanceCorrectionAmount);
}
public void addDisbursementAmount(final Money disbursementAmount) {
this.disbursementAmount = MathUtil.plus(this.disbursementAmount, disbursementAmount, mc);
public void addDisbursementAmount(final Money additionalDisbursementAmount) {
this.disbursementAmount = MathUtil.plus(this.getDisbursementAmount(), additionalDisbursementAmount, getMc());
}
public void addCreditedPrincipalAmount(final Money creditedPrincipal) {
this.creditedPrincipal = MathUtil.plus(this.creditedPrincipal, creditedPrincipal, mc);
public void addCreditedPrincipalAmount(final Money additionalCreditedPrincipal) {
this.creditedPrincipal = MathUtil.plus(this.getCreditedPrincipal(), additionalCreditedPrincipal, getMc());
}
public void addCreditedInterestAmount(final Money creditedInterest) {
this.creditedInterest = MathUtil.plus(this.creditedInterest, creditedInterest, mc);
public void addCreditedInterestAmount(final Money additionalCreditedInterest) {
this.creditedInterest = MathUtil.plus(this.getCreditedInterest(), additionalCreditedInterest, getMc());
}
public void addCapitalizedIncomePrincipalAmount(final Money capitalizedIncomePrincipal) {
this.capitalizedIncomePrincipal = MathUtil.plus(this.capitalizedIncomePrincipal, capitalizedIncomePrincipal, mc);
public void addCapitalizedIncomePrincipalAmount(final Money additionalCapitalizedIncomePrincipal) {
this.capitalizedIncomePrincipal = MathUtil.plus(this.getCapitalizedIncomePrincipal(), additionalCapitalizedIncomePrincipal,
getMc());
}
public BigDecimal getCalculatedDueInterest() {
if (isPaused) {
return creditedInterest.getAmount();
if (isPaused()) {
return getCreditedInterest().getAmount();
}
long lengthTillPeriodDueDate = getLengthTillPeriodDueDate();
final BigDecimal interestDueTillRepaymentDueDate = getCalculatedDueInterest(
getRepaymentPeriod().getLoanProductRelatedDetail().getInterestMethod(), lengthTillPeriodDueDate); //
return MathUtil.negativeToZero(MathUtil.add(mc, creditedInterest.getAmount(), interestDueTillRepaymentDueDate));
return MathUtil.negativeToZero(MathUtil.add(getMc(), getCreditedInterest().getAmount(), interestDueTillRepaymentDueDate));
}
public BigDecimal getCalculatedDueInterest(InterestMethod method, long lengthTillPeriodDueDate) {
@@ -150,22 +152,22 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
return BigDecimal.ZERO;
}
BigDecimal baseAmount = switch (method) {
case FLAT -> MathUtil.nullToZero(getRepaymentPeriod().calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(this));
case FLAT -> getRepaymentPeriod().calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(this).getAmount();
case DECLINING_BALANCE -> getOutstandingLoanBalance().getAmount();
default -> throw new UnsupportedOperationException("Method not implemented: " + method);
};
return baseAmount //
.multiply(getRateFactorTillPeriodDueDate(), mc) //
.divide(BigDecimal.valueOf(lengthTillPeriodDueDate), mc) //
.multiply(BigDecimal.valueOf(getLength()), mc);
.multiply(getRateFactorTillPeriodDueDate(), getMc()) //
.divide(BigDecimal.valueOf(lengthTillPeriodDueDate), getMc()) //
.multiply(BigDecimal.valueOf(getLength()), getMc());
}
public long getLength() {
return DateUtils.getDifferenceInDays(fromDate, dueDate);
return DateUtils.getDifferenceInDays(getFromDate(), getDueDate());
}
public long getLengthTillPeriodDueDate() {
return DateUtils.getDifferenceInDays(fromDate, repaymentPeriod.getDueDate());
return DateUtils.getDifferenceInDays(getFromDate(), getRepaymentPeriod().getDueDate());
}
public void updateOutstandingLoanBalance() {
@@ -174,19 +176,19 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
if (previousRepaymentPeriod.isPresent()) {
InterestPeriod previousInterestPeriod = previousRepaymentPeriod.get().getLastInterestPeriod();
this.outstandingLoanBalance = MathUtil.negativeToZero(previousInterestPeriod.getOutstandingLoanBalance()//
.plus(previousInterestPeriod.getDisbursementAmount(), mc)//
.plus(previousInterestPeriod.getCapitalizedIncomePrincipal(), mc)//
.plus(previousInterestPeriod.getBalanceCorrectionAmount(), mc)//
.minus(previousRepaymentPeriod.get().getDuePrincipal(), mc)//
.plus(previousRepaymentPeriod.get().getPaidPrincipal(), mc), mc);//
.plus(previousInterestPeriod.getDisbursementAmount(), getMc())//
.plus(previousInterestPeriod.getCapitalizedIncomePrincipal(), getMc())//
.plus(previousInterestPeriod.getBalanceCorrectionAmount(), getMc())//
.minus(previousRepaymentPeriod.get().getDuePrincipal(), getMc())//
.plus(previousRepaymentPeriod.get().getPaidPrincipal(), getMc()), getMc());//
}
} else {
int index = getRepaymentPeriod().getInterestPeriods().indexOf(this);
InterestPeriod previousInterestPeriod = getRepaymentPeriod().getInterestPeriods().get(index - 1);
this.outstandingLoanBalance = MathUtil.negativeToZero(previousInterestPeriod.getOutstandingLoanBalance() //
.plus(previousInterestPeriod.getBalanceCorrectionAmount(), mc) //
.plus(previousInterestPeriod.getCapitalizedIncomePrincipal(), mc) //
.plus(previousInterestPeriod.getDisbursementAmount(), mc)); //
.plus(previousInterestPeriod.getBalanceCorrectionAmount(), getMc()) //
.plus(previousInterestPeriod.getCapitalizedIncomePrincipal(), getMc()) //
.plus(previousInterestPeriod.getDisbursementAmount(), getMc())); //
}
}
@@ -194,10 +196,47 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
* Include principal like amounts (all disbursement amount + credited principal)
*/
public Money getCreditedAmounts() {
return getDisbursementAmount().plus(getCreditedPrincipal(), mc).plus(getCapitalizedIncomePrincipal(), mc);
return MathUtil.plus(mc, getDisbursementAmount(), getCreditedPrincipal(), getCapitalizedIncomePrincipal());
}
public boolean isFirstInterestPeriod() {
return this.equals(getRepaymentPeriod().getFirstInterestPeriod());
}
private MonetaryCurrency getCurrency() {
return getRepaymentPeriod().getCurrency();
}
public Money getCreditedPrincipal() {
return MathUtil.nullToZero(creditedPrincipal, getCurrency(), getMc());
}
public Money getCreditedInterest() {
return MathUtil.nullToZero(creditedInterest, getCurrency(), getMc());
}
public Money getDisbursementAmount() {
return MathUtil.nullToZero(disbursementAmount, getCurrency(), getMc());
}
public Money getBalanceCorrectionAmount() {
return MathUtil.nullToZero(balanceCorrectionAmount, getCurrency(), getMc());
}
public Money getOutstandingLoanBalance() {
return MathUtil.nullToZero(outstandingLoanBalance, getCurrency(), getMc());
}
public Money getCapitalizedIncomePrincipal() {
return MathUtil.nullToZero(capitalizedIncomePrincipal, getCurrency(), getMc());
}
public BigDecimal getRateFactor() {
return MathUtil.nullToZero(rateFactor);
}
public BigDecimal getRateFactorTillPeriodDueDate() {
return MathUtil.nullToZero(rateFactorTillPeriodDueDate);
}
}
@@ -33,13 +33,14 @@ import lombok.Setter;
import lombok.ToString;
import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
import org.apache.fineract.portfolio.util.Memo;
@ToString(exclude = { "previous" })
@EqualsAndHashCode(exclude = { "previous" })
public final class RepaymentPeriod {
public class RepaymentPeriod {
@JsonExclude
private final RepaymentPeriod previous;
@@ -52,20 +53,16 @@ public final class RepaymentPeriod {
@Setter
private List<InterestPeriod> interestPeriods;
@Setter
@Getter
private Money emi;
@Setter
@Getter
private Money originalEmi;
@Getter
private Money paidPrincipal;
@Getter
private Money paidInterest;
@Setter
@Getter
private Money futureUnrecognizedInterest;
@JsonExclude
@Getter
private final MathContext mc;
@JsonExclude
@@ -81,18 +78,17 @@ public final class RepaymentPeriod {
private boolean isInterestMoved = false;
@Setter
@Getter
private BigDecimal totalDisbursedAmount;
private Money totalDisbursedAmount;
@Setter
@Getter
private BigDecimal totalCapitalizedIncomeAmount;
private Money totalCapitalizedIncomeAmount;
@JsonExclude
@Getter
private final LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail;
@JsonExclude
private MonetaryCurrency currency;
private RepaymentPeriod(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, List<InterestPeriod> interestPeriods,
protected RepaymentPeriod(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, List<InterestPeriod> interestPeriods,
Money emi, Money originalEmi, Money paidPrincipal, Money paidInterest, Money futureUnrecognizedInterest, MathContext mc,
LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail) {
this.previous = previous;
@@ -119,34 +115,34 @@ public final class RepaymentPeriod {
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, fromDate, dueDate, new ArrayList<>(), emi, emi, zero, zero,
zero, mc, loanProductRelatedDetail);
// There is always at least 1 interest period, by default with same from-due date as repayment period
newRepaymentPeriod.interestPeriods.add(InterestPeriod.withEmptyAmounts(newRepaymentPeriod, fromDate, dueDate));
newRepaymentPeriod.getInterestPeriods().add(InterestPeriod.withEmptyAmounts(newRepaymentPeriod, fromDate, dueDate));
return newRepaymentPeriod;
}
public static RepaymentPeriod copy(RepaymentPeriod previous, RepaymentPeriod repaymentPeriod, MathContext mc) {
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, repaymentPeriod.fromDate, repaymentPeriod.dueDate,
new ArrayList<>(), repaymentPeriod.emi, repaymentPeriod.originalEmi, repaymentPeriod.paidPrincipal,
repaymentPeriod.paidInterest, repaymentPeriod.futureUnrecognizedInterest, mc,
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, repaymentPeriod.getFromDate(),
repaymentPeriod.getDueDate(), new ArrayList<>(), repaymentPeriod.getEmi(), repaymentPeriod.getOriginalEmi(),
repaymentPeriod.getPaidPrincipal(), repaymentPeriod.getPaidInterest(), repaymentPeriod.getFutureUnrecognizedInterest(), mc,
repaymentPeriod.getLoanProductRelatedDetail());
// There is always at least 1 interest period, by default with same from-due date as repayment period
for (InterestPeriod interestPeriod : repaymentPeriod.interestPeriods) {
newRepaymentPeriod.interestPeriods.add(InterestPeriod.copy(newRepaymentPeriod, interestPeriod, mc));
for (InterestPeriod interestPeriod : repaymentPeriod.getInterestPeriods()) {
newRepaymentPeriod.getInterestPeriods().add(InterestPeriod.copy(newRepaymentPeriod, interestPeriod, mc));
}
return newRepaymentPeriod;
}
public static RepaymentPeriod copyWithoutPaidAmounts(RepaymentPeriod previous, RepaymentPeriod repaymentPeriod, MathContext mc) {
final Money zero = repaymentPeriod.emi.zero();
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, repaymentPeriod.fromDate, repaymentPeriod.dueDate,
new ArrayList<>(), repaymentPeriod.emi, repaymentPeriod.originalEmi, zero, zero, zero, mc,
repaymentPeriod.getLoanProductRelatedDetail());
final Money zero = Money.zero(repaymentPeriod.getCurrency(), mc);
final RepaymentPeriod newRepaymentPeriod = new RepaymentPeriod(previous, repaymentPeriod.getFromDate(),
repaymentPeriod.getDueDate(), new ArrayList<>(), repaymentPeriod.getEmi(), repaymentPeriod.getOriginalEmi(), zero, zero,
zero, mc, repaymentPeriod.getLoanProductRelatedDetail());
// There is always at least 1 interest period, by default with same from-due date as repayment period
for (InterestPeriod interestPeriod : repaymentPeriod.interestPeriods) {
for (InterestPeriod interestPeriod : repaymentPeriod.getInterestPeriods()) {
var interestPeriodCopy = InterestPeriod.copy(newRepaymentPeriod, interestPeriod);
if (!interestPeriodCopy.getBalanceCorrectionAmount().isZero()) {
interestPeriodCopy.addBalanceCorrectionAmount(interestPeriodCopy.getBalanceCorrectionAmount().negated());
}
newRepaymentPeriod.interestPeriods.add(interestPeriodCopy);
newRepaymentPeriod.getInterestPeriods().add(interestPeriodCopy);
}
return newRepaymentPeriod;
}
@@ -186,17 +182,17 @@ public final class RepaymentPeriod {
}
public Money calculateCalculatedDueInterest() {
Money calculatedDueInterest = getZero(mc);
if (!isInterestMoved) {
calculatedDueInterest = Money.of(emi.getCurrencyData(),
Money calculatedDueInterest = getZero();
if (!isInterestMoved()) {
calculatedDueInterest = Money.of(getEmi().getCurrencyData(),
getInterestPeriods().stream().map(InterestPeriod::getCalculatedDueInterest).reduce(BigDecimal.ZERO, BigDecimal::add),
mc);
}
calculatedDueInterest = calculatedDueInterest.add(getFutureUnrecognizedInterest(), mc);
calculatedDueInterest = calculatedDueInterest.add(getFutureUnrecognizedInterest(), getMc());
if (getPrevious().isPresent()) {
calculatedDueInterest = calculatedDueInterest.add(getPrevious().get().getUnrecognizedInterest(), mc);
calculatedDueInterest = calculatedDueInterest.add(getPrevious().get().getUnrecognizedInterest(), getMc());
}
return MathUtil.negativeToZero(calculatedDueInterest, mc);
return MathUtil.negativeToZero(calculatedDueInterest, getMc());
}
/**
@@ -223,7 +219,7 @@ public final class RepaymentPeriod {
* @return
*/
public Money getEmiPlusCreditedAmountsPlusFutureUnrecognizedInterest() {
return getEmi().plus(getTotalCreditedAmount(), mc).plus(getFutureUnrecognizedInterest(), mc); //
return getEmi().plus(getTotalCreditedAmount(), mc).plus(getFutureUnrecognizedInterest(), getMc()); //
}
/**
@@ -232,7 +228,8 @@ public final class RepaymentPeriod {
* @return
*/
public Money getCalculatedDuePrincipal() {
return MathUtil.negativeToZero(getEmiPlusCreditedAmountsPlusFutureUnrecognizedInterest().minus(getCalculatedDueInterest(), mc), mc);
return MathUtil.negativeToZero(getEmiPlusCreditedAmountsPlusFutureUnrecognizedInterest().minus(getCalculatedDueInterest(), getMc()),
getMc());
}
/**
@@ -241,9 +238,9 @@ public final class RepaymentPeriod {
* @return
*/
public Money getCreditedPrincipal() {
return MathUtil.negativeToZero(interestPeriods.stream() //
return MathUtil.negativeToZero(getInterestPeriods().stream() //
.map(InterestPeriod::getCreditedPrincipal) //
.reduce(getZero(mc), (value, previous) -> value.plus(previous, mc)), mc); //
.reduce(getZero(), (value, previous) -> value.plus(previous, getMc())), getMc()); //
}
/**
@@ -252,9 +249,9 @@ public final class RepaymentPeriod {
* @return
*/
public Money getCreditedInterest() {
return MathUtil.negativeToZero(interestPeriods.stream() //
return MathUtil.negativeToZero(getInterestPeriods().stream() //
.map(InterestPeriod::getCreditedInterest) //
.reduce(getZero(mc), (value, previous) -> value.plus(previous, mc)), mc); //
.reduce(getZero(), (value, previous) -> value.plus(previous, getMc())), getMc()); //
}
/**
@@ -263,9 +260,9 @@ public final class RepaymentPeriod {
* @return
*/
public Money getCapitalizedIncomePrincipal() {
return MathUtil.negativeToZero(interestPeriods.stream() //
return MathUtil.negativeToZero(getInterestPeriods().stream() //
.map(InterestPeriod::getCapitalizedIncomePrincipal) //
.reduce(getZero(mc), (value, previous) -> value.plus(previous, mc)), mc); //
.reduce(getZero(), (value, previous) -> value.plus(previous, getMc())), getMc()); //
}
/**
@@ -275,8 +272,8 @@ public final class RepaymentPeriod {
*/
public Money getDuePrincipal() {
// Due principal might be the maximum paid if there is pay-off or early repayment
return MathUtil.max(
MathUtil.negativeToZero(getEmiPlusCreditedAmountsPlusFutureUnrecognizedInterest().minus(getDueInterest(), mc), mc),
return MathUtil.max(MathUtil
.negativeToZero(getEmiPlusCreditedAmountsPlusFutureUnrecognizedInterest().minus(getDueInterest(), getMc()), getMc()),
getPaidPrincipal(), false);
}
@@ -286,7 +283,7 @@ public final class RepaymentPeriod {
* @return
*/
public Money getTotalCreditedAmount() {
return getCreditedPrincipal().plus(getCreditedInterest(), mc);
return getCreditedPrincipal().plus(getCreditedInterest(), getMc());
}
/**
@@ -295,7 +292,7 @@ public final class RepaymentPeriod {
* @return
*/
public Money getTotalPaidAmount() {
return getPaidPrincipal().plus(getPaidInterest());
return getPaidPrincipal().plus(getPaidInterest(), getMc());
}
public boolean isFullyPaid() {
@@ -309,35 +306,35 @@ public final class RepaymentPeriod {
* @return
*/
public Money getUnrecognizedInterest() {
return getCalculatedDueInterest().minus(getDueInterest(), mc);
return getCalculatedDueInterest().minus(getDueInterest(), getMc());
}
public Money getCreditedAmounts() {
return interestPeriods.stream().map(InterestPeriod::getCreditedAmounts).reduce(getZero(mc), (m1, m2) -> m1.plus(m2, mc));
return interestPeriods.stream().map(InterestPeriod::getCreditedAmounts).reduce(getZero(), (m1, m2) -> m1.plus(m2, getMc()));
}
public Money getOutstandingLoanBalance() {
if (outstandingBalanceCalculation == null) {
outstandingBalanceCalculation = Memo.of(() -> {
InterestPeriod lastInterestPeriod = getInterestPeriods().get(getInterestPeriods().size() - 1);
InterestPeriod lastInterestPeriod = getInterestPeriods().getLast();
Money calculatedOutStandingLoanBalance = lastInterestPeriod.getOutstandingLoanBalance() //
.plus(lastInterestPeriod.getBalanceCorrectionAmount(), mc) //
.plus(lastInterestPeriod.getCapitalizedIncomePrincipal(), mc) //
.plus(lastInterestPeriod.getDisbursementAmount(), mc) //
.minus(getDuePrincipal(), mc)//
.plus(getPaidPrincipal(), mc);//
return MathUtil.negativeToZero(calculatedOutStandingLoanBalance, mc);
.plus(lastInterestPeriod.getBalanceCorrectionAmount(), getMc()) //
.plus(lastInterestPeriod.getCapitalizedIncomePrincipal(), getMc()) //
.plus(lastInterestPeriod.getDisbursementAmount(), getMc()) //
.plus(getPaidPrincipal(), getMc()) //
.minus(getDuePrincipal(), getMc()); //
return MathUtil.negativeToZero(calculatedOutStandingLoanBalance, getMc());
}, () -> new Object[] { paidPrincipal, paidInterest, interestPeriods, totalDisbursedAmount });
}
return outstandingBalanceCalculation.get();
}
public void addPaidPrincipalAmount(Money paidPrincipal) {
this.paidPrincipal = MathUtil.plus(this.paidPrincipal, paidPrincipal, mc);
this.paidPrincipal = MathUtil.plus(this.getPaidPrincipal(), paidPrincipal, getMc());
}
public void addPaidInterestAmount(Money paidInterest) {
this.paidInterest = MathUtil.plus(this.paidInterest, paidInterest, mc);
this.paidInterest = MathUtil.plus(this.getPaidInterest(), paidInterest, getMc());
}
public Money getInitialBalanceForEmiRecalculation() {
@@ -345,29 +342,28 @@ public final class RepaymentPeriod {
if (getPrevious().isPresent()) {
initialBalance = getPrevious().get().getOutstandingLoanBalance();
} else {
initialBalance = getZero(mc);
initialBalance = getZero();
}
Money totalDisbursedAmount = getInterestPeriods().stream() //
.map(InterestPeriod::getDisbursementAmount) //
.reduce(getZero(mc), (m1, m2) -> m1.plus(m2, mc)); //
.reduce(getZero(), (m1, m2) -> m1.plus(m2, getMc())); //
Money totalCapitalizedIncomeAmount = getInterestPeriods().stream() //
.map(InterestPeriod::getCapitalizedIncomePrincipal) //
.reduce(getZero(mc), (m1, m2) -> m1.plus(m2, mc)); //
return initialBalance.add(totalDisbursedAmount, mc).add(totalCapitalizedIncomeAmount, mc);
.reduce(getZero(), (m1, m2) -> m1.plus(m2, getMc())); //
return initialBalance.add(totalDisbursedAmount, getMc()).add(totalCapitalizedIncomeAmount, getMc());
}
private Money getZero(MathContext mc) {
// EMI is always initiated
return this.emi.zero(mc);
public Money getZero() {
return Money.zero(getCurrency(), getMc());
}
public InterestPeriod getFirstInterestPeriod() {
return getInterestPeriods().get(0);
return getInterestPeriods().getFirst();
}
public InterestPeriod getLastInterestPeriod() {
List<InterestPeriod> interestPeriods = getInterestPeriods();
return interestPeriods.get(interestPeriods.size() - 1);
return interestPeriods.getLast();
}
public Optional<InterestPeriod> findInterestPeriod(@NotNull LocalDate transactionDate) {
@@ -387,11 +383,11 @@ public final class RepaymentPeriod {
* @return
*/
public Money getOutstandingInterest() {
return MathUtil.negativeToZero(getDueInterest().minus(getPaidInterest()), mc);
return MathUtil.negativeToZero(getDueInterest().minus(getPaidInterest()), getMc());
}
public Money getOutstandingPrincipal() {
return MathUtil.negativeToZero(getDuePrincipal().minus(getPaidPrincipal()), mc);
return MathUtil.negativeToZero(getDuePrincipal().minus(getPaidPrincipal()), getMc());
}
public void resetDerivedComponents() {
@@ -404,21 +400,56 @@ public final class RepaymentPeriod {
* can be null. if null it calculates total disbursement including last interest period.
* @return disbursed and capitalized income amount till interest period.
*/
public BigDecimal calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(InterestPeriod tillPeriod) {
BigDecimal res = MathUtil.nullToZero(getTotalDisbursedAmount()).add(MathUtil.nullToZero(getTotalCapitalizedIncomeAmount()));
for (InterestPeriod interestPeriod : this.interestPeriods) {
public Money calculateTotalDisbursedAndCapitalizedIncomeAmountTillGivenPeriod(InterestPeriod tillPeriod) {
Money res = MathUtil.plus(getMc(), getTotalDisbursedAmount(), getTotalCapitalizedIncomeAmount());
for (InterestPeriod interestPeriod : this.getInterestPeriods()) {
if (interestPeriod.equals(tillPeriod)) {
break;
}
if (!interestPeriod.getDueDate().equals(getFromDate())) {
if (interestPeriod.getDisbursementAmount() != null) {
res = res.add(interestPeriod.getDisbursementAmount().getAmount());
res = res.plus(interestPeriod.getDisbursementAmount(), getMc());
}
if (interestPeriod.getCapitalizedIncomePrincipal() != null) {
res = res.add(interestPeriod.getCapitalizedIncomePrincipal().getAmount());
res = res.plus(interestPeriod.getCapitalizedIncomePrincipal(), getMc());
}
}
}
return res;
}
public MonetaryCurrency getCurrency() {
if (currency == null) {
currency = MonetaryCurrency.fromCurrencyData(loanProductRelatedDetail.getCurrencyData());
}
return currency;
}
public Money getEmi() {
return MathUtil.nullToZero(emi, getCurrency(), getMc());
}
public Money getOriginalEmi() {
return MathUtil.nullToZero(originalEmi, getCurrency(), getMc());
}
public Money getPaidPrincipal() {
return MathUtil.nullToZero(paidPrincipal, getCurrency(), getMc());
}
public Money getPaidInterest() {
return MathUtil.nullToZero(paidInterest, getCurrency(), getMc());
}
public Money getFutureUnrecognizedInterest() {
return MathUtil.nullToZero(futureUnrecognizedInterest, getCurrency(), getMc());
}
public Money getTotalDisbursedAmount() {
return MathUtil.nullToZero(totalDisbursedAmount, getCurrency(), getMc());
}
public Money getTotalCapitalizedIncomeAmount() {
return MathUtil.nullToZero(totalCapitalizedIncomeAmount, getCurrency(), getMc());
}
}
@@ -0,0 +1,138 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanproduct.calc.data;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanproduct.domain.InterestCalculationPeriodMethod;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
class InterestPeriodTest {
private static final MathContext MC = new MathContext(12, RoundingMode.HALF_EVEN);
private static final CurrencyData USD = new CurrencyData("USD", "US Dollar", 2, 1, "$", "USD");
private static final Money ZERO = Money.of(USD, BigDecimal.ZERO, MC);
private static MockedStatic<MoneyHelper> moneyHelper;
@BeforeAll
static void init() {
moneyHelper = Mockito.mockStatic(MoneyHelper.class);
moneyHelper.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.HALF_EVEN);
moneyHelper.when(MoneyHelper::getMathContext).thenReturn(MC);
}
@Test
void testGettersNeverReturnNull() {
// Create an empty InterestPeriod with all null Money fields
RepaymentPeriod repaymentPeriod = createMinimalRepaymentPeriod();
InterestPeriod period = InterestPeriod.empty(repaymentPeriod, MC);
// Test all Money getters
assertNotNull(period.getCreditedPrincipal());
assertNotNull(period.getCreditedInterest());
assertNotNull(period.getDisbursementAmount());
assertNotNull(period.getBalanceCorrectionAmount());
assertNotNull(period.getOutstandingLoanBalance());
assertNotNull(period.getCapitalizedIncomePrincipal());
// Test BigDecimal getters
assertNotNull(period.getRateFactor());
assertNotNull(period.getRateFactorTillPeriodDueDate());
}
@Test
void testMethodsDoNotThrowNPE() {
RepaymentPeriod repaymentPeriod = createMinimalRepaymentPeriod();
InterestPeriod period = InterestPeriod.empty(repaymentPeriod, MC);
when(repaymentPeriod.getInterestPeriods()).thenReturn(List.of(period));
when(repaymentPeriod.getFirstInterestPeriod()).thenReturn(period);
LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail = mock(
LoanProductMinimumRepaymentScheduleRelatedDetail.class);
when(loanProductRelatedDetail.getCurrencyData()).thenReturn(USD);
when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.DECLINING_BALANCE);
when(loanProductRelatedDetail.getInterestCalculationPeriodMethod()).thenReturn(InterestCalculationPeriodMethod.DAILY);
when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
when(repaymentPeriod.getLoanProductRelatedDetail()).thenReturn(loanProductRelatedDetail);
// Set some test data
period.setFromDate(LocalDate.now(ZoneId.of("UTC")));
period.setDueDate(LocalDate.now(ZoneId.of("UTC")).plusDays(30));
period.setRateFactor(BigDecimal.valueOf(0.01));
period.setRateFactorTillPeriodDueDate(BigDecimal.valueOf(0.01));
// Test methods that perform calculations
assertDoesNotThrow(period::updateOutstandingLoanBalance);
assertDoesNotThrow(period::getCreditedAmounts);
assertDoesNotThrow(() -> period.getCalculatedDueInterest());
assertDoesNotThrow(() -> period.getCalculatedDueInterest(InterestMethod.DECLINING_BALANCE, 30));
assertDoesNotThrow(period::getLength);
assertDoesNotThrow(period::getLengthTillPeriodDueDate);
}
@Test
void testWithNullFields() {
RepaymentPeriod repaymentPeriod = createMinimalRepaymentPeriod();
InterestPeriod period = new InterestPeriod(repaymentPeriod, null, // fromDate
null, // dueDate
null, // rateFactor
null, // rateFactorTillPeriodDueDate
null, // creditedPrincipal
null, // creditedInterest
null, // disbursementAmount
null, // balanceCorrectionAmount
null, // outstandingLoanBalance
null, // capitalizedIncomePrincipal
MC, false // isPaused
);
// Test that getters don't throw and return non-null
assertDoesNotThrow(period::getCreditedPrincipal);
assertDoesNotThrow(period::getCreditedInterest);
assertDoesNotThrow(period::getDisbursementAmount);
assertDoesNotThrow(period::getBalanceCorrectionAmount);
assertDoesNotThrow(period::getOutstandingLoanBalance);
assertDoesNotThrow(period::getCapitalizedIncomePrincipal);
}
private RepaymentPeriod createMinimalRepaymentPeriod() {
RepaymentPeriod repaymentPeriod = mock(RepaymentPeriod.class);
when(repaymentPeriod.getZero()).thenReturn(ZERO);
when(repaymentPeriod.getDueDate()).thenReturn(LocalDate.now(ZoneId.of("UTC")).plusMonths(1));
when(repaymentPeriod.getCurrency()).thenReturn(ZERO.getCurrency());
when(repaymentPeriod.getMc()).thenReturn(MC);
return repaymentPeriod;
}
}
@@ -0,0 +1,130 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanproduct.calc.data;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanproduct.domain.InterestCalculationPeriodMethod;
import org.apache.fineract.portfolio.loanproduct.domain.InterestMethod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinimumRepaymentScheduleRelatedDetail;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
class RepaymentPeriodTest {
private static final MathContext MC = new MathContext(12, RoundingMode.HALF_EVEN);
private static final CurrencyData USD = new CurrencyData("USD", "US Dollar", 2, 1, "$", "USD");
private static final Money ZERO = Money.of(USD, BigDecimal.ZERO, MC);
private static MockedStatic<MoneyHelper> moneyHelper;
private static LoanProductMinimumRepaymentScheduleRelatedDetail loanProductRelatedDetail;
@BeforeAll
static void init() {
moneyHelper = Mockito.mockStatic(MoneyHelper.class);
moneyHelper.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.HALF_EVEN);
moneyHelper.when(MoneyHelper::getMathContext).thenReturn(MC);
loanProductRelatedDetail = mock(LoanProductMinimumRepaymentScheduleRelatedDetail.class);
when(loanProductRelatedDetail.getCurrencyData()).thenReturn(USD);
when(loanProductRelatedDetail.getInterestMethod()).thenReturn(InterestMethod.DECLINING_BALANCE);
when(loanProductRelatedDetail.getInterestCalculationPeriodMethod()).thenReturn(InterestCalculationPeriodMethod.DAILY);
when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true);
}
@Test
void testGettersNeverReturnNull() {
// Create a minimal RepaymentPeriod
LocalDate now = LocalDate.now(ZoneId.of("UTC"));
Money emi = ZERO;
RepaymentPeriod period = RepaymentPeriod.create(null, now, now.plusMonths(1), emi, MC, loanProductRelatedDetail);
// Test all Money getters
assertNotNull(period.getEmi());
assertNotNull(period.getOriginalEmi());
assertNotNull(period.getPaidPrincipal());
assertNotNull(period.getPaidInterest());
assertNotNull(period.getFutureUnrecognizedInterest());
// Test BigDecimal getters
assertNotNull(period.getTotalDisbursedAmount());
assertNotNull(period.getTotalCapitalizedIncomeAmount());
}
@Test
void testMethodsDoNotThrowNPE() {
LocalDate now = LocalDate.now(ZoneId.of("UTC"));
Money emi = ZERO;
RepaymentPeriod period = RepaymentPeriod.create(null, now, now.plusMonths(1), emi, MC, loanProductRelatedDetail);
// Add an interest period
InterestPeriod interestPeriod = InterestPeriod.withEmptyAmounts(period, now, now.plusMonths(1));
period.getInterestPeriods().add(interestPeriod);
// Test methods that perform calculations
assertDoesNotThrow(period::getCalculatedDueInterest);
assertDoesNotThrow(period::getDueInterest);
assertDoesNotThrow(period::getCalculatedDuePrincipal);
assertDoesNotThrow(period::getDuePrincipal);
assertDoesNotThrow(period::getTotalCreditedAmount);
assertDoesNotThrow(period::getTotalPaidAmount);
assertDoesNotThrow(period::getUnrecognizedInterest);
assertDoesNotThrow(period::getCreditedAmounts);
assertDoesNotThrow(period::getOutstandingLoanBalance);
assertDoesNotThrow(period::getInitialBalanceForEmiRecalculation);
}
@Test
void testEmptyRepaymentPeriod() {
// Create an empty repayment period with all null fields
RepaymentPeriod period = new RepaymentPeriod(null, // previous
null, // fromDate
null, // dueDate
null, // interestPeriods
null, // emi
null, // originalEmi
null, // paidPrincipal
null, // paidInterest
null, // futureUnrecognizedInterest
MC, // mc
loanProductRelatedDetail);
// Test that getters don't throw and return non-null
assertDoesNotThrow(period::getEmi);
assertDoesNotThrow(period::getOriginalEmi);
assertDoesNotThrow(period::getPaidPrincipal);
assertDoesNotThrow(period::getPaidInterest);
assertDoesNotThrow(period::getFutureUnrecognizedInterest);
assertDoesNotThrow(period::getTotalDisbursedAmount);
assertDoesNotThrow(period::getTotalCapitalizedIncomeAmount);
}
}