FINERACT-2232: add income capitalization principal handling and repayment schedule calculations on loans

This commit is contained in:
mark.vituska
2025-05-27 18:35:39 +02:00
committed by Adam Saghy
parent c89ec910d8
commit aac62a92bb
38 changed files with 686 additions and 148 deletions
@@ -30,7 +30,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanSchedul
*/
@RequiredArgsConstructor
@Getter
public final class DisbursementData implements Comparable<DisbursementData> {
public final class DisbursementData implements LoanPrincipalRelatedDataHolder, Comparable<DisbursementData> {
private final Long id;
private final LocalDate expectedDisbursementDate;
@@ -102,8 +102,7 @@ public final class DisbursementData implements Comparable<DisbursementData> {
private boolean occursOnDayFromAndIncludingAndUpTo(final LocalDate fromInclusive, final LocalDate upToNotInclusive,
final LocalDate target) {
return (DateUtils.isEqual(target, fromInclusive) || DateUtils.isAfter(target, fromInclusive))
&& DateUtils.isBefore(target, upToNotInclusive);
return DateUtils.isDateInRangeFromInclusiveToExclusive(fromInclusive, upToNotInclusive, target);
}
public BigDecimal getWaivedChargeAmount() {
@@ -34,6 +34,9 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData;
public class LoanSummaryData {
private final CurrencyData currency;
private final BigDecimal totalPrincipal;
private final BigDecimal totalCapitalizedIncome;
private final BigDecimal totalCapitalizedIncomeAdjustment;
private final BigDecimal principalDisbursed;
private final BigDecimal principalAdjustments;
private final BigDecimal principalPaid;
@@ -38,6 +38,15 @@ import org.apache.fineract.organisation.monetary.domain.Money;
public class LoanSummary {
// derived totals fields
@Column(name = "total_principal_derived", scale = 6, precision = 19)
private BigDecimal totalPrincipal;
@Column(name = "capitalized_income_derived", scale = 6, precision = 19)
private BigDecimal totalCapitalizedIncome;
@Column(name = "capitalized_income_adjustment_derived", scale = 6, precision = 19)
private BigDecimal totalCapitalizedIncomeAdjustment;
@Column(name = "principal_disbursed_derived", scale = 6, precision = 19)
private BigDecimal totalPrincipalDisbursed;
@@ -166,6 +175,9 @@ public class LoanSummary {
this.totalPenaltyChargesWaived = BigDecimal.ZERO;
this.totalPenaltyChargesWrittenOff = BigDecimal.ZERO;
this.totalPrincipalAdjustments = BigDecimal.ZERO;
this.totalPrincipal = BigDecimal.ZERO;
this.totalCapitalizedIncome = BigDecimal.ZERO;
this.totalCapitalizedIncomeAdjustment = BigDecimal.ZERO;
this.totalPrincipalDisbursed = BigDecimal.ZERO;
this.totalPrincipalOutstanding = BigDecimal.ZERO;
this.totalPrincipalRepaid = BigDecimal.ZERO;
@@ -176,17 +188,20 @@ public class LoanSummary {
}
public void updateSummary(final MonetaryCurrency currency, final Money principal,
final List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments, Set<LoanCharge> charges) {
final List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments, Set<LoanCharge> charges, Money capitalizedIncome,
Money capitalizedIncomeAdjustment) {
this.totalPrincipalDisbursed = principal.getAmount();
this.totalCapitalizedIncome = capitalizedIncome.getAmount();
this.totalCapitalizedIncomeAdjustment = capitalizedIncomeAdjustment.getAmount();
this.totalPrincipal = principal.plus(capitalizedIncome).getAmount();
this.totalPrincipalAdjustments = calculateTotalPrincipalAdjusted(repaymentScheduleInstallments, currency).getAmount();
this.totalFeeAdjustments = calculateTotalFeeAdjusted(repaymentScheduleInstallments, currency).getAmount();
this.totalPenaltyAdjustments = calculateTotalPenaltyAdjusted(repaymentScheduleInstallments, currency).getAmount();
this.totalPrincipalRepaid = calculateTotalPrincipalRepaid(repaymentScheduleInstallments, currency).getAmount();
this.totalPrincipalWrittenOff = calculateTotalPrincipalWrittenOff(repaymentScheduleInstallments, currency).getAmount();
this.totalPrincipalOutstanding = principal.plus(this.totalPrincipalAdjustments).minus(this.totalPrincipalRepaid)
.minus(this.totalPrincipalWrittenOff).getAmount();
this.totalPrincipalOutstanding = principal.plus(capitalizedIncome).plus(this.totalPrincipalAdjustments)
.minus(this.totalPrincipalRepaid).minus(this.totalPrincipalWrittenOff).getAmount();
final Money totalInterestCharged = calculateTotalInterestCharged(repaymentScheduleInstallments, currency);
this.totalInterestCharged = totalInterestCharged.getAmount();
@@ -225,7 +240,7 @@ public class LoanSummary {
this.totalPenaltyChargesOutstanding = totalPenaltyChargesCharged.minus(this.totalPenaltyChargesRepaid)
.minus(this.totalPenaltyChargesWaived).minus(this.totalPenaltyChargesWrittenOff).getAmount();
final Money totalExpectedRepayment = Money.of(currency, this.totalPrincipalDisbursed).plus(this.totalInterestCharged)
final Money totalExpectedRepayment = Money.of(currency, this.totalPrincipal).plus(this.totalInterestCharged)
.plus(this.totalFeeChargesCharged).plus(this.totalPenaltyChargesCharged);
this.totalExpectedRepayment = totalExpectedRepayment.getAmount();
@@ -0,0 +1,53 @@
/**
* 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.loanaccount.domain;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.apache.fineract.portfolio.loanaccount.data.LoanPrincipalRelatedDataHolder;
/**
* Immutable data object representing a subset of loan transaction data.
*/
@Getter
@EqualsAndHashCode
public class LoanTransactionRepaymentPeriodData implements LoanPrincipalRelatedDataHolder, Serializable {
private final Long transactionId;
private final Long loanId;
private final LocalDate date;
private final boolean reversed;
private final BigDecimal amount;
private final BigDecimal unrecognizedAmount;
private final BigDecimal feeChargesPortion;
public LoanTransactionRepaymentPeriodData(Long transactionId, Long loanId, LocalDate date, boolean reversed, BigDecimal amount,
BigDecimal unrecognizedAmount, BigDecimal feeChargesPortion) {
this.transactionId = transactionId;
this.loanId = loanId;
this.date = date;
this.reversed = reversed;
this.amount = amount;
this.unrecognizedAmount = unrecognizedAmount;
this.feeChargesPortion = feeChargesPortion;
}
}
@@ -104,7 +104,10 @@ public final class LoanRefundValidator {
if (loan.getLoanProduct().isMultiDisburseLoan() && adjustedTransaction == null) {
final BigDecimal totalDisbursed = loan.getDisbursedAmount();
final BigDecimal totalPrincipalAdjusted = loan.getSummary().getTotalPrincipalAdjustments();
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted);
final BigDecimal totalCapitalizedIncome = loan.getLoanTransactions(LoanTransaction::isCapitalizedIncome).stream()
.filter(LoanTransaction::isNotReversed).map(LoanTransaction::getPrincipalPortion)
.reduce(BigDecimal.ZERO, BigDecimal::add);
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted).add(totalCapitalizedIncome);
if (totalPrincipalCredited.compareTo(loan.getSummary().getTotalPrincipalRepaid()) < 0) {
final String errorMessage = "The transaction amount cannot exceed threshold.";
throw new InvalidLoanStateTransitionException("transaction", "amount.exceeds.threshold", errorMessage);
@@ -0,0 +1,29 @@
/**
* 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.loanaccount.service;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
public interface CapitalizedIncomeBalanceService {
Money calculateCapitalizedIncome(Loan loan);
Money calculateCapitalizedIncomeAdjustment(Loan loan);
}
@@ -0,0 +1,31 @@
/**
* 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.loanaccount.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.springframework.transaction.annotation.Transactional;
public interface CapitalizedIncomePlatformService {
@Transactional
CommandProcessingResult addCapitalizedIncome(Long loanId, JsonCommand command);
void resetBalance(Long loanId);
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -35,8 +36,11 @@ import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransact
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LoanBalanceService {
private final CapitalizedIncomeBalanceService capitalizedIncomeBalanceService;
public Money calculateTotalOverpayment(final Loan loan) {
Money totalPaidInRepayments = loan.getTotalPaidInRepayments();
@@ -97,7 +101,10 @@ public class LoanBalanceService {
loan.setTotalRecovered(recoveredAmount.getAmountDefaultedToNullIfZero());
final Money principal = loan.getLoanRepaymentScheduleDetail().getPrincipal();
loan.getSummary().updateSummary(loan.getCurrency(), principal, loan.getRepaymentScheduleInstallments(), loan.getLoanCharges());
final Money capitalizedIncome = capitalizedIncomeBalanceService.calculateCapitalizedIncome(loan);
final Money capitalizedIncomeAdjustment = capitalizedIncomeBalanceService.calculateCapitalizedIncomeAdjustment(loan);
loan.getSummary().updateSummary(loan.getCurrency(), principal, loan.getRepaymentScheduleInstallments(), loan.getLoanCharges(),
capitalizedIncome, capitalizedIncomeAdjustment);
loan.updateLoanOutstandingBalances();
}
}
@@ -173,7 +173,10 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
if (loan.getLoanProduct().isMultiDisburseLoan()) {
final BigDecimal totalDisbursed = loan.getDisbursedAmount();
final BigDecimal totalPrincipalAdjusted = loan.getSummary().getTotalPrincipalAdjustments();
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted);
final BigDecimal totalCapitalizedIncome = loan.getLoanTransactions(LoanTransaction::isCapitalizedIncome).stream()
.filter(LoanTransaction::isNotReversed).map(LoanTransaction::getPrincipalPortion)
.reduce(BigDecimal.ZERO, BigDecimal::add);
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted).add(totalCapitalizedIncome);
if (totalPrincipalCredited.compareTo(loan.getSummary().getTotalPrincipalRepaid()) < 0
&& loan.repaymentScheduleDetail().getPrincipal().minus(totalDisbursed).isGreaterThanZero()) {
final String errorMessage = "The transaction amount cannot exceed threshold.";
@@ -37,6 +37,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
import org.apache.fineract.portfolio.loanaccount.data.PaidInAdvanceData;
import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData;
@@ -50,7 +51,8 @@ public interface LoanReadPlatformService {
LoanAccountData fetchRepaymentScheduleData(LoanAccountData accountData);
LoanScheduleData retrieveRepaymentSchedule(Long loanId, RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData,
Collection<DisbursementData> disbursementData, boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType);
Collection<DisbursementData> disbursementData, Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData,
boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType);
Collection<LoanTransactionData> retrieveLoanTransactions(Long loanId);