FINERACT-2080: Extract calculations from loan entity

This commit is contained in:
Oleksii Novikov
2025-05-22 13:58:44 +02:00
committed by Adam Saghy
parent 18a55874ba
commit 47d5330802
53 changed files with 1403 additions and 1128 deletions
@@ -18,6 +18,9 @@
*/
package org.apache.fineract.portfolio.delinquency.service;
import static org.apache.fineract.portfolio.loanaccount.domain.Loan.EARLIEST_UNPAID_DATE;
import static org.apache.fineract.portfolio.loanaccount.domain.Loan.NEXT_UNPAID_DUE_DATE;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Comparator;
@@ -27,6 +30,7 @@ import java.util.stream.Collector;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData;
@@ -51,6 +55,7 @@ import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
import org.apache.fineract.portfolio.loanaccount.data.DelinquencyPausePeriod;
import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.jetbrains.annotations.NotNull;
@@ -142,7 +147,7 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
// loans
collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList);
collectionData.setAvailableDisbursementAmount(loan.getApprovedPrincipal().subtract(loan.getDisbursedAmount()));
collectionData.setNextPaymentDueDate(loan.possibleNextRepaymentDate(nextPaymentDueDateConfig));
collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan));
final LoanTransaction lastPayment = loan.getLastPaymentTransaction();
if (lastPayment != null) {
@@ -228,4 +233,63 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
return List.of();
}
private LocalDate possibleNextRepaymentDate(final String nextPaymentDueDateConfig, final Loan loan) {
if (nextPaymentDueDateConfig == null) {
return null;
}
return switch (nextPaymentDueDateConfig.toLowerCase()) {
case EARLIEST_UNPAID_DATE -> getEarliestUnpaidInstallmentDate(loan);
case NEXT_UNPAID_DUE_DATE -> getNextUnpaidInstallmentDueDate(loan);
default -> null;
};
}
private LocalDate getEarliestUnpaidInstallmentDate(final Loan loan) {
LocalDate earliestUnpaidInstallmentDate = DateUtils.getBusinessLocalDate();
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
if (installment.isNotFullyPaidOff()) {
earliestUnpaidInstallmentDate = installment.getDueDate();
break;
}
}
LocalDate lastTransactionDate = null;
for (final LoanTransaction transaction : loan.getLoanTransactions()) {
if (transaction.isRepaymentLikeType() && transaction.isGreaterThanZero()) {
lastTransactionDate = transaction.getTransactionDate();
}
}
LocalDate possibleNextRepaymentDate = earliestUnpaidInstallmentDate;
if (DateUtils.isAfter(lastTransactionDate, earliestUnpaidInstallmentDate)) {
possibleNextRepaymentDate = lastTransactionDate;
}
return possibleNextRepaymentDate;
}
private LocalDate getNextUnpaidInstallmentDueDate(final Loan loan) {
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
LocalDate currentBusinessDate = DateUtils.getBusinessLocalDate();
LocalDate expectedMaturityDate = loan.determineExpectedMaturityDate();
LocalDate nextUnpaidInstallmentDate = expectedMaturityDate;
for (final LoanRepaymentScheduleInstallment installment : installments) {
boolean isCurrentDateBeforeInstallmentAndLoanPeriod = DateUtils.isBefore(currentBusinessDate, installment.getDueDate())
&& DateUtils.isBefore(currentBusinessDate, expectedMaturityDate);
if (installment.isDownPayment()) {
isCurrentDateBeforeInstallmentAndLoanPeriod = DateUtils.isEqual(currentBusinessDate, installment.getDueDate())
&& DateUtils.isBefore(currentBusinessDate, expectedMaturityDate);
}
if (isCurrentDateBeforeInstallmentAndLoanPeriod) {
if (installment.isNotFullyPaidOff()) {
nextUnpaidInstallmentDate = installment.getDueDate();
break;
}
}
}
return nextUnpaidInstallmentDate;
}
}
@@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.springframework.stereotype.Component;
// TODO: introduce tests for the state machine
@@ -36,6 +37,7 @@ public class DefaultLoanLifecycleStateMachine implements LoanLifecycleStateMachi
private static final List<LoanStatus> ALLOWED_LOAN_STATUSES = List.of(LoanStatus.values());
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanBalanceService loanBalanceService;
@Override
public LoanStatus dryTransition(final LoanEvent loanEvent, final Loan loan) {
@@ -45,7 +47,7 @@ public class DefaultLoanLifecycleStateMachine implements LoanLifecycleStateMachi
@Override
public void transition(final LoanEvent loanEvent, final Loan loan) {
loan.updateLoanSummaryDerivedFields();
loanBalanceService.updateLoanSummaryDerivedFields(loan);
internalTransition(loanEvent, loan);
}
@@ -55,7 +57,7 @@ public class DefaultLoanLifecycleStateMachine implements LoanLifecycleStateMachi
return;
}
loan.updateLoanSummaryDerivedFields();
loanBalanceService.updateLoanSummaryDerivedFields(loan);
final LoanStatusTransition transition = determineTransition(loan, loan.getStatus(), transactionDate);
@@ -67,7 +67,6 @@ import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.staff.domain.Staff;
import org.apache.fineract.portfolio.accountdetails.domain.AccountType;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.collateral.domain.LoanCollateral;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
@@ -76,7 +75,6 @@ import org.apache.fineract.portfolio.group.domain.Group;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel;
import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
@@ -275,6 +273,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "interest_calculated_from_date")
private LocalDate interestChargedFromDate;
@Setter
@Column(name = "total_overpaid_derived", scale = 6, precision = 19)
private BigDecimal totalOverpaid;
@@ -347,6 +346,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@OneToMany(cascade = CascadeType.ALL, mappedBy = "loan", orphanRemoval = true, fetch = FetchType.LAZY)
private List<LoanTermVariations> loanTermVariations = new ArrayList<>();
@Setter
@Column(name = "total_recovered_derived", scale = 6, precision = 19)
private BigDecimal totalRecovered;
@@ -716,28 +716,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
}
}
public void updateLoanSummaryDerivedFields() {
if (isNotDisbursed()) {
if (this.summary != null) {
this.summary.zeroFields();
}
this.totalOverpaid = null;
} else {
final Money overpaidBy = calculateTotalOverpayment();
this.totalOverpaid = null;
if (!overpaidBy.isLessThanZero()) {
this.totalOverpaid = overpaidBy.getAmountDefaultedToNullIfZero();
}
final Money recoveredAmount = calculateTotalRecoveredPayments();
this.totalRecovered = recoveredAmount.getAmountDefaultedToNullIfZero();
final Money principal = this.loanRepaymentScheduleDetail.getPrincipal();
this.summary.updateSummary(getCurrency(), principal, getRepaymentScheduleInstallments(), this.charges);
updateLoanOutstandingBalances();
}
}
private boolean isInterestRecalculationEnabledForProduct() {
return this.loanProduct.isInterestRecalculationEnabled();
}
@@ -746,33 +724,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.loanProduct.isMultiDisburseLoan();
}
public BigDecimal calculateOverdueAmountPercentageAppliedTo(final LoanCharge loanCharge, final int penaltyWaitPeriod) {
LoanRepaymentScheduleInstallment installment = loanCharge.getOverdueInstallmentCharge().getInstallment();
LocalDate graceDate = DateUtils.getBusinessLocalDate().minusDays(penaltyWaitPeriod);
Money amount = Money.zero(getCurrency());
if (DateUtils.isAfter(graceDate, installment.getDueDate())) {
amount = calculateOverdueAmountPercentageAppliedTo(installment, loanCharge.getChargeCalculation());
if (!amount.isGreaterThanZero()) {
loanCharge.setActive(false);
}
} else {
loanCharge.setActive(false);
}
return amount.getAmount();
}
private Money calculateOverdueAmountPercentageAppliedTo(LoanRepaymentScheduleInstallment installment,
ChargeCalculationType calculationType) {
return switch (calculationType) {
case PERCENT_OF_AMOUNT -> installment.getPrincipalOutstanding(getCurrency());
case PERCENT_OF_AMOUNT_AND_INTEREST ->
installment.getPrincipalOutstanding(getCurrency()).plus(installment.getInterestOutstanding(getCurrency()));
case PERCENT_OF_INTEREST -> installment.getInterestOutstanding(getCurrency());
default -> Money.zero(getCurrency());
};
}
public List<Long> fetchLoanTrancheChargeIds() {
return getCharges().stream()//
.filter(charge -> charge.isTrancheDisbursementCharge() && charge.isActive()) //
@@ -793,7 +744,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
.collect(Collectors.toList());
}
private LocalDate determineExpectedMaturityDate() {
public LocalDate determineExpectedMaturityDate() {
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments().stream()
.filter(i -> !i.isDownPayment() && !i.isAdditional()).toList();
final int numberOfInstallments = installments.size();
@@ -918,16 +869,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.postDatedChecks = new ArrayList<>();
}
public List<LoanTransaction> retrieveListOfTransactionsForReprocessing() {
return getLoanTransactions().stream().filter(loanTransactionForReprocessingPredicate()).sorted(LoanTransactionComparator.INSTANCE)
.collect(Collectors.toList());
}
private static Predicate<LoanTransaction> loanTransactionForReprocessingPredicate() {
return transaction -> transaction.isNotReversed() && (transaction.isChargeOff() || transaction.isReAge()
|| transaction.isAccrualActivity() || transaction.isReAmortize() || !transaction.isNonMonetaryTransaction());
}
public List<LoanTransaction> retrieveListOfTransactionsExcludeAccruals() {
final List<LoanTransaction> repaymentsOrWaivers = new ArrayList<>();
for (final LoanTransaction transaction : this.loanTransactions) {
@@ -945,95 +886,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
.sorted(LoanTransactionComparator.INSTANCE).collect(Collectors.toList());
}
public boolean isChronologicallyLatestRepaymentOrWaiver(final LoanTransaction loanTransaction) {
boolean isChronologicallyLatestRepaymentOrWaiver = true;
final LocalDate currentTransactionDate = loanTransaction.getTransactionDate();
for (final LoanTransaction previousTransaction : loanTransactions) {
if (!previousTransaction.isDisbursement() && previousTransaction.isNotReversed()
&& (DateUtils.isBefore(currentTransactionDate, previousTransaction.getTransactionDate())
|| (DateUtils.isEqual(currentTransactionDate, previousTransaction.getTransactionDate())
&& ((loanTransaction.getId() == null && previousTransaction.getId() == null)
|| (loanTransaction.getId() != null && (previousTransaction.getId() == null
|| loanTransaction.getId().compareTo(previousTransaction.getId()) < 0)))))) {
isChronologicallyLatestRepaymentOrWaiver = false;
break;
}
}
return isChronologicallyLatestRepaymentOrWaiver;
}
public boolean isAfterLastRepayment(final LoanTransaction loanTransaction, final List<LoanTransaction> loanTransactions) {
return loanTransactions.stream() //
.filter(t -> t.isRepaymentLikeType() && t.isNotReversed()) //
.noneMatch(t -> DateUtils.isBefore(loanTransaction.getTransactionDate(), t.getTransactionDate()));
}
public boolean isChronologicallyLatestTransaction(final LoanTransaction loanTransaction, final List<LoanTransaction> loanTransactions) {
return loanTransactions.stream() //
.filter(LoanTransaction::isNotReversed) //
.allMatch(t -> DateUtils.isAfter(loanTransaction.getTransactionDate(), t.getTransactionDate()));
}
public LocalDate possibleNextRepaymentDate(final String nextPaymentDueDateConfig) {
if (nextPaymentDueDateConfig == null) {
return null;
}
return switch (nextPaymentDueDateConfig.toLowerCase()) {
case EARLIEST_UNPAID_DATE -> getEarliestUnpaidInstallmentDate();
case NEXT_UNPAID_DUE_DATE -> getNextUnpaidInstallmentDueDate();
default -> null;
};
}
private LocalDate getNextUnpaidInstallmentDueDate() {
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
LocalDate currentBusinessDate = DateUtils.getBusinessLocalDate();
LocalDate expectedMaturityDate = determineExpectedMaturityDate();
LocalDate nextUnpaidInstallmentDate = expectedMaturityDate;
for (final LoanRepaymentScheduleInstallment installment : installments) {
boolean isCurrentDateBeforeInstallmentAndLoanPeriod = DateUtils.isBefore(currentBusinessDate, installment.getDueDate())
&& DateUtils.isBefore(currentBusinessDate, expectedMaturityDate);
if (installment.isDownPayment()) {
isCurrentDateBeforeInstallmentAndLoanPeriod = DateUtils.isEqual(currentBusinessDate, installment.getDueDate())
&& DateUtils.isBefore(currentBusinessDate, expectedMaturityDate);
}
if (isCurrentDateBeforeInstallmentAndLoanPeriod) {
if (installment.isNotFullyPaidOff()) {
nextUnpaidInstallmentDate = installment.getDueDate();
break;
}
}
}
return nextUnpaidInstallmentDate;
}
private LocalDate getEarliestUnpaidInstallmentDate() {
LocalDate earliestUnpaidInstallmentDate = DateUtils.getBusinessLocalDate();
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
if (installment.isNotFullyPaidOff()) {
earliestUnpaidInstallmentDate = installment.getDueDate();
break;
}
}
LocalDate lastTransactionDate = null;
for (final LoanTransaction transaction : this.loanTransactions) {
if (transaction.isRepaymentLikeType() && transaction.isGreaterThanZero()) {
lastTransactionDate = transaction.getTransactionDate();
}
}
LocalDate possibleNextRepaymentDate = earliestUnpaidInstallmentDate;
if (DateUtils.isAfter(lastTransactionDate, earliestUnpaidInstallmentDate)) {
possibleNextRepaymentDate = lastTransactionDate;
}
return possibleNextRepaymentDate;
}
public LoanTransaction findWriteOffTransaction() {
return this.loanTransactions.stream() //
.filter(transaction -> !transaction.isReversed() && transaction.isWriteOff()) //
@@ -1041,49 +899,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
.orElse(null);
}
public boolean isOverPaid() {
return calculateTotalOverpayment().isGreaterThanZero();
}
public Money calculateTotalOverpayment() {
Money totalPaidInRepayments = getTotalPaidInRepayments();
final MonetaryCurrency currency = getCurrency();
Money cumulativeTotalPaidOnInstallments = Money.zero(currency);
Money cumulativeTotalWaivedOnInstallments = Money.zero(currency);
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment scheduledRepayment : installments) {
cumulativeTotalPaidOnInstallments = cumulativeTotalPaidOnInstallments
.plus(scheduledRepayment.getPrincipalCompleted(currency).plus(scheduledRepayment.getInterestPaid(currency)))
.plus(scheduledRepayment.getFeeChargesPaid(currency)).plus(scheduledRepayment.getPenaltyChargesPaid(currency));
cumulativeTotalWaivedOnInstallments = cumulativeTotalWaivedOnInstallments.plus(scheduledRepayment.getInterestWaived(currency));
}
for (final LoanTransaction loanTransaction : this.loanTransactions) {
if (loanTransaction.isReversed()) {
continue;
}
if (loanTransaction.isRefund() || loanTransaction.isRefundForActiveLoan()) {
totalPaidInRepayments = totalPaidInRepayments.minus(loanTransaction.getAmount(currency));
} else if (loanTransaction.isCreditBalanceRefund()) {
if (loanTransaction.getPrincipalPortion(currency).isZero()) {
totalPaidInRepayments = totalPaidInRepayments.minus(loanTransaction.getOverPaymentPortion(currency));
}
} else if (loanTransaction.isChargeback()) {
if (loanTransaction.getPrincipalPortion(currency).isZero() && getCreditAllocationRules().stream() //
.filter(car -> car.getTransactionType().equals(CreditAllocationTransactionType.CHARGEBACK)) //
.findAny() //
.isEmpty()) {
totalPaidInRepayments = totalPaidInRepayments.minus(loanTransaction.getOverPaymentPortion(currency));
}
}
}
// if total paid in transactions doesn't match repayment schedule then there's an overpayment.
return totalPaidInRepayments.minus(cumulativeTotalPaidOnInstallments);
}
public Money calculateTotalRecoveredPayments() {
// in case logic for reversing recovered payment is implemented handle subtraction from totalRecoveredPayments
return getTotalRecoveredPayments();
@@ -1392,30 +1207,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return isChargedOff() && getChargeOffReason() != null ? getChargeOffReason().getId() : null;
}
public Money getReceivableInterest(final LocalDate tillDate) {
Money receivableInterest = Money.zero(getCurrency());
for (final LoanTransaction transaction : this.loanTransactions) {
if (transaction.isNotReversed() && !transaction.isRepaymentAtDisbursement() && !transaction.isDisbursement()
&& !DateUtils.isAfter(transaction.getTransactionDate(), tillDate)) {
if (transaction.isAccrual()) {
receivableInterest = receivableInterest.plus(transaction.getInterestPortion(getCurrency()));
} else if (transaction.isRepaymentLikeType() || transaction.isInterestWaiver() || transaction.isAccrualAdjustment()) {
receivableInterest = receivableInterest.minus(transaction.getInterestPortion(getCurrency()));
}
}
if (receivableInterest.isLessThanZero()) {
receivableInterest = receivableInterest.zero();
}
/*
* if (transaction.getTransactionDate().isAfter(tillDate) && transaction.isAccrual()) { final String
* errorMessage = "The date on which a loan is interest waived cannot be in after accrual transactions." ;
* throw new InvalidLoanStateTransitionException("waive", "cannot.be.after.accrual.date", errorMessage,
* tillDate); }
*/
}
return receivableInterest;
}
public boolean isSyncDisbursementWithMeeting() {
return this.syncDisbursementWithMeeting != null && this.syncDisbursementWithMeeting;
}
@@ -1480,13 +1271,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return currentTransactionDate;
}
public LoanTransaction getLastTransactionForReprocessing() {
return loanTransactions.stream() //
.filter(Loan.loanTransactionForReprocessingPredicate()) //
.reduce((first, second) -> second) //
.orElse(null);
}
public LoanTransaction getLastPaymentTransaction() {
return loanTransactions.stream() //
.filter(loanTransaction -> !loanTransaction.isReversed()) //
@@ -1507,6 +1291,17 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.charges == null ? new HashSet<>() : this.charges.stream().filter(LoanCharge::isActive).collect(Collectors.toSet());
}
public LoanCharge fetchLoanChargesById(final Long id) {
LoanCharge charge = null;
for (LoanCharge loanCharge : this.charges) {
if (id.equals(loanCharge.getId())) {
charge = loanCharge;
break;
}
}
return charge;
}
public List<LoanDisbursementDetails> getAllDisbursementDetails() {
return this.disbursementDetails;
}
@@ -1800,21 +1595,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
}
}
public void updateLoanToLastDisbursalState(LoanDisbursementDetails disbursementDetail) {
for (final LoanCharge charge : getActiveCharges()) {
if (charge.isOverdueInstallmentCharge()) {
charge.setActive(false);
} else if (charge.isTrancheDisbursementCharge() && disbursementDetail.getDisbursementDate()
.equals(charge.getTrancheDisbursementCharge().getloanDisbursementDetails().actualDisbursementDate())) {
charge.resetToOriginal(getCurrency());
}
}
this.loanRepaymentScheduleDetail.setPrincipal(getDisbursedAmount().subtract(disbursementDetail.principal()));
disbursementDetail.updateActualDisbursementDate(null);
disbursementDetail.reverse();
updateLoanSummaryDerivedFields();
}
private int adjustNumberOfRepayments() {
int repaymetsForAdjust = 0;
for (LoanTermVariations loanTermVariations : this.loanTermVariations) {
@@ -1867,145 +1647,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.writeOffReason = writeOffReason;
}
public LoanRepaymentScheduleInstallment fetchLoanForeclosureDetail(final LocalDate closureDate) {
Money[] receivables = retrieveIncomeOutstandingTillDate(closureDate);
Money totalPrincipal = Money.of(getCurrency(), this.getSummary().getTotalPrincipalOutstanding());
totalPrincipal = totalPrincipal.minus(receivables[3]);
final LocalDate currentDate = DateUtils.getBusinessLocalDate();
return new LoanRepaymentScheduleInstallment(null, 0, currentDate, currentDate, totalPrincipal.getAmount(),
receivables[0].getAmount(), receivables[1].getAmount(), receivables[2].getAmount(), false, null);
}
private Money[] retrieveIncomeOutstandingTillDate(final LocalDate paymentDate) {
Money[] balances = new Money[4];
final MonetaryCurrency currency = getCurrency();
Money interest = Money.zero(currency);
Money paidFromFutureInstallments = Money.zero(currency);
Money fee = Money.zero(currency);
Money penalty = Money.zero(currency);
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(repaymentScheduleInstallments);
for (final LoanRepaymentScheduleInstallment installment : this.repaymentScheduleInstallments) {
boolean isFirstNormalInstallment = installment.getInstallmentNumber().equals(firstNormalInstallmentNumber);
if (!DateUtils.isBefore(paymentDate, installment.getDueDate())) {
interest = interest.plus(installment.getInterestOutstanding(currency));
penalty = penalty.plus(installment.getPenaltyChargesOutstanding(currency));
fee = fee.plus(installment.getFeeChargesOutstanding(currency));
} else if (DateUtils.isAfter(paymentDate, installment.getFromDate())) {
Money[] balancesForCurrentPeroid = fetchInterestFeeAndPenaltyTillDate(paymentDate, currency, installment,
isFirstNormalInstallment);
if (balancesForCurrentPeroid[0].isGreaterThan(balancesForCurrentPeroid[5])) {
interest = interest.plus(balancesForCurrentPeroid[0]).minus(balancesForCurrentPeroid[5]);
} else {
paidFromFutureInstallments = paidFromFutureInstallments.plus(balancesForCurrentPeroid[5])
.minus(balancesForCurrentPeroid[0]);
}
if (balancesForCurrentPeroid[1].isGreaterThan(balancesForCurrentPeroid[3])) {
fee = fee.plus(balancesForCurrentPeroid[1].minus(balancesForCurrentPeroid[3]));
} else {
paidFromFutureInstallments = paidFromFutureInstallments
.plus(balancesForCurrentPeroid[3].minus(balancesForCurrentPeroid[1]));
}
if (balancesForCurrentPeroid[2].isGreaterThan(balancesForCurrentPeroid[4])) {
penalty = penalty.plus(balancesForCurrentPeroid[2].minus(balancesForCurrentPeroid[4]));
} else {
paidFromFutureInstallments = paidFromFutureInstallments.plus(balancesForCurrentPeroid[4])
.minus(balancesForCurrentPeroid[2]);
}
} else {
paidFromFutureInstallments = paidFromFutureInstallments.plus(installment.getInterestPaid(currency))
.plus(installment.getPenaltyChargesPaid(currency)).plus(installment.getFeeChargesPaid(currency));
}
}
balances[0] = interest;
balances[1] = fee;
balances[2] = penalty;
balances[3] = paidFromFutureInstallments;
return balances;
}
private Money[] fetchInterestFeeAndPenaltyTillDate(final LocalDate paymentDate, final MonetaryCurrency currency,
final LoanRepaymentScheduleInstallment installment, boolean isFirstNormalInstallment) {
Money penaltyForCurrentPeriod = Money.zero(getCurrency());
Money penaltyAccoutedForCurrentPeriod = Money.zero(getCurrency());
Money feeForCurrentPeriod = Money.zero(getCurrency());
Money feeAccountedForCurrentPeriod = Money.zero(getCurrency());
int totalPeriodDays = DateUtils.getExactDifferenceInDays(installment.getFromDate(), installment.getDueDate());
int tillDays = DateUtils.getExactDifferenceInDays(installment.getFromDate(), paymentDate);
Money interestForCurrentPeriod = Money.of(getCurrency(), BigDecimal
.valueOf(calculateInterestForDays(totalPeriodDays, installment.getInterestCharged(getCurrency()).getAmount(), tillDays)));
Money interestAccountedForCurrentPeriod = installment.getInterestWaived(getCurrency())
.plus(installment.getInterestPaid(getCurrency()));
for (LoanCharge loanCharge : this.charges) {
if (loanCharge.isActive() && !loanCharge.isDueAtDisbursement()) {
boolean isDue = loanCharge.isDueInPeriod(installment.getFromDate(), paymentDate, isFirstNormalInstallment);
if (isDue) {
if (loanCharge.isPenaltyCharge()) {
penaltyForCurrentPeriod = penaltyForCurrentPeriod.plus(loanCharge.getAmount(getCurrency()));
penaltyAccoutedForCurrentPeriod = penaltyAccoutedForCurrentPeriod
.plus(loanCharge.getAmountWaived(getCurrency()).plus(loanCharge.getAmountPaid(getCurrency())));
} else {
feeForCurrentPeriod = feeForCurrentPeriod.plus(loanCharge.getAmount(currency));
feeAccountedForCurrentPeriod = feeAccountedForCurrentPeriod.plus(loanCharge.getAmountWaived(getCurrency()).plus(
loanCharge.getAmountPaid(getCurrency())));
}
} else if (loanCharge.isInstalmentFee()) {
LoanInstallmentCharge loanInstallmentCharge = loanCharge.getInstallmentLoanCharge(installment.getInstallmentNumber());
if (loanCharge.isPenaltyCharge()) {
penaltyAccoutedForCurrentPeriod = penaltyAccoutedForCurrentPeriod
.plus(loanInstallmentCharge.getAmountPaid(currency));
} else {
feeAccountedForCurrentPeriod = feeAccountedForCurrentPeriod.plus(loanInstallmentCharge.getAmountPaid(currency));
}
}
}
}
Money[] balances = new Money[6];
balances[0] = interestForCurrentPeriod;
balances[1] = feeForCurrentPeriod;
balances[2] = penaltyForCurrentPeriod;
balances[3] = feeAccountedForCurrentPeriod;
balances[4] = penaltyAccoutedForCurrentPeriod;
balances[5] = interestAccountedForCurrentPeriod;
return balances;
}
public Money[] retrieveIncomeForOverlappingPeriod(final LocalDate paymentDate) {
Money[] balances = new Money[3];
final MonetaryCurrency currency = getCurrency();
balances[0] = balances[1] = balances[2] = Money.zero(currency);
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(repaymentScheduleInstallments);
for (final LoanRepaymentScheduleInstallment installment : this.repaymentScheduleInstallments) {
boolean isFirstNormalInstallment = installment.getInstallmentNumber().equals(firstNormalInstallmentNumber);
if (DateUtils.isEqual(paymentDate, installment.getDueDate())) {
Money interest = installment.getInterestCharged(currency);
Money fee = installment.getFeeChargesCharged(currency);
Money penalty = installment.getPenaltyChargesCharged(currency);
balances[0] = interest;
balances[1] = fee;
balances[2] = penalty;
break;
} else if (DateUtils.isDateInRangeExclusive(paymentDate, installment.getFromDate(), installment.getDueDate())) {
balances = fetchInterestFeeAndPenaltyTillDate(paymentDate, currency, installment, isFirstNormalInstallment);
break;
}
}
return balances;
}
private double calculateInterestForDays(int daysInPeriod, BigDecimal interest, int days) {
if (interest.doubleValue() == 0) {
return 0;
}
return interest.doubleValue() / daysInPeriod * days;
}
public void updateLoanScheduleOnForeclosure(final Collection<LoanRepaymentScheduleInstallment> installments) {
this.repaymentScheduleInstallments.clear();
for (final LoanRepaymentScheduleInstallment installment : installments) {
@@ -2046,7 +1687,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.loanTopupDetails;
}
public Collection<LoanCharge> getLoanCharges() {
public Set<LoanCharge> getLoanCharges() {
return this.charges;
}
@@ -33,18 +33,13 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
@@ -56,11 +51,11 @@ import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.apache.fineract.portfolio.charge.exception.LoanChargeWithoutMandatoryFieldException;
import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData;
import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidDetail;
import org.apache.fineract.portfolio.loanaccount.data.LoanInstallmentChargeData;
@Setter
@Getter
@Entity
@Table(name = "m_loan_charge", uniqueConstraints = { @UniqueConstraint(columnNames = { "external_id" }, name = "external_id") })
@@ -104,7 +99,6 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "amount_paid_derived", scale = 6, precision = 19)
private BigDecimal amountPaid;
@Setter
@Column(name = "amount_waived_derived", scale = 6, precision = 19)
private BigDecimal amountWaived;
@@ -149,111 +143,6 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@OneToMany(mappedBy = "loanCharge", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private Set<LoanChargePaidBy> loanChargePaidBySet = new HashSet<>();
protected LoanCharge() {
//
}
public LoanCharge(final Loan loan, final Charge chargeDefinition, final BigDecimal loanPrincipal, final BigDecimal amount,
final ChargeTimeType chargeTime, final ChargeCalculationType chargeCalculation, final LocalDate dueDate,
final ChargePaymentMode chargePaymentMode, final Integer numberOfRepayments, final BigDecimal loanCharge,
final ExternalId externalId) {
this.loan = loan;
this.charge = chargeDefinition;
this.submittedOnDate = DateUtils.getBusinessLocalDate();
this.penaltyCharge = chargeDefinition.isPenalty();
this.minCap = chargeDefinition.getMinCap();
this.maxCap = chargeDefinition.getMaxCap();
this.chargeTime = chargeDefinition.getChargeTimeType();
if (chargeTime != null) {
this.chargeTime = chargeTime.getValue();
}
if (ChargeTimeType.fromInt(this.chargeTime).equals(ChargeTimeType.SPECIFIED_DUE_DATE)
|| ChargeTimeType.fromInt(this.chargeTime).equals(ChargeTimeType.OVERDUE_INSTALLMENT)) {
if (dueDate == null) {
final String defaultUserMessage = "Loan charge is missing due date.";
throw new LoanChargeWithoutMandatoryFieldException("loanCharge", "dueDate", defaultUserMessage, chargeDefinition.getId(),
chargeDefinition.getName());
}
this.dueDate = dueDate;
} else {
this.dueDate = null;
}
this.chargeCalculation = chargeDefinition.getChargeCalculation();
if (chargeCalculation != null) {
this.chargeCalculation = chargeCalculation.getValue();
}
BigDecimal chargeAmount = chargeDefinition.getAmount();
if (amount != null) {
chargeAmount = amount;
}
this.chargePaymentMode = chargeDefinition.getChargePaymentMode();
if (chargePaymentMode != null) {
this.chargePaymentMode = chargePaymentMode.getValue();
}
populateDerivedFields(loanPrincipal, chargeAmount, numberOfRepayments, loanCharge);
this.paid = determineIfFullyPaid();
this.externalId = externalId;
}
public void populateDerivedFields(final BigDecimal amountPercentageAppliedTo, final BigDecimal chargeAmount, Integer numberOfRepayments,
BigDecimal loanCharge) {
switch (ChargeCalculationType.fromInt(this.chargeCalculation)) {
case INVALID:
this.percentage = null;
this.amount = null;
this.amountPercentageAppliedTo = null;
this.amountPaid = null;
this.amountOutstanding = BigDecimal.ZERO;
this.amountWaived = null;
this.amountWrittenOff = null;
break;
case FLAT:
this.percentage = null;
this.amountPercentageAppliedTo = null;
this.amountPaid = null;
if (isInstalmentFee()) {
if (numberOfRepayments == null) {
numberOfRepayments = this.loan.fetchNumberOfInstallmensAfterExceptions();
}
this.amount = chargeAmount.multiply(BigDecimal.valueOf(numberOfRepayments));
} else {
this.amount = chargeAmount;
}
this.amountOutstanding = this.amount;
this.amountWaived = null;
this.amountWrittenOff = null;
break;
case PERCENT_OF_AMOUNT:
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:
this.percentage = chargeAmount;
this.amountPercentageAppliedTo = amountPercentageAppliedTo;
if (loanCharge.compareTo(BigDecimal.ZERO) == 0) {
loanCharge = percentageOf(this.amountPercentageAppliedTo);
}
this.amount = minimumAndMaximumCap(loanCharge);
this.amountPaid = null;
this.amountOutstanding = calculateOutstanding();
this.amountWaived = null;
this.amountWrittenOff = null;
break;
}
this.amountOrPercentage = chargeAmount;
if (this.loan != null && isInstalmentFee()) {
updateInstallmentCharges();
}
}
public void markAsFullyPaid() {
this.amountPaid = this.amount;
this.amountOutstanding = BigDecimal.ZERO;
@@ -340,234 +229,6 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.loan = loan;
}
public void update(final BigDecimal amount, final LocalDate dueDate, final BigDecimal loanPrincipal, Integer numberOfRepayments,
BigDecimal loanCharge) {
if (dueDate != null) {
this.dueDate = dueDate;
}
if (amount != null) {
switch (ChargeCalculationType.fromInt(this.chargeCalculation)) {
case INVALID:
break;
case FLAT:
if (isInstalmentFee()) {
if (numberOfRepayments == null) {
numberOfRepayments = this.loan.fetchNumberOfInstallmensAfterExceptions();
}
this.amount = amount.multiply(BigDecimal.valueOf(numberOfRepayments));
} else {
this.amount = amount;
}
break;
case PERCENT_OF_AMOUNT:
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:
this.percentage = amount;
this.amountPercentageAppliedTo = loanPrincipal;
if (loanCharge.compareTo(BigDecimal.ZERO) == 0) {
loanCharge = percentageOf(this.amountPercentageAppliedTo);
}
this.amount = minimumAndMaximumCap(loanCharge);
break;
}
this.amountOrPercentage = amount;
this.amountOutstanding = calculateOutstanding();
if (this.loan != null && isInstalmentFee()) {
updateInstallmentCharges();
}
}
}
public void update(final BigDecimal amount, final LocalDate dueDate, final Integer numberOfRepayments) {
BigDecimal amountPercentageAppliedTo = BigDecimal.ZERO;
if (this.loan != null) {
switch (ChargeCalculationType.fromInt(this.chargeCalculation)) {
case PERCENT_OF_AMOUNT:
// If charge type is specified due date and loan is multi disburment loan.
// Then we need to get as of this loan charge due date how much amount disbursed.
if (this.loan.isMultiDisburmentLoan() && this.isSpecifiedDueDate()) {
for (final LoanDisbursementDetails loanDisbursementDetails : this.loan.getDisbursementDetails()) {
if (!DateUtils.isAfter(loanDisbursementDetails.expectedDisbursementDate(), this.getDueDate())) {
amountPercentageAppliedTo = amountPercentageAppliedTo.add(loanDisbursementDetails.principal());
}
}
} else {
amountPercentageAppliedTo = this.loan.getPrincipal().getAmount();
}
break;
case PERCENT_OF_AMOUNT_AND_INTEREST:
amountPercentageAppliedTo = this.loan.getPrincipal().getAmount().add(this.loan.getTotalInterest());
break;
case PERCENT_OF_INTEREST:
amountPercentageAppliedTo = this.loan.getTotalInterest();
break;
case PERCENT_OF_DISBURSEMENT_AMOUNT:
LoanTrancheDisbursementCharge loanTrancheDisbursementCharge = this.loanTrancheDisbursementCharge;
amountPercentageAppliedTo = loanTrancheDisbursementCharge.getloanDisbursementDetails().principal();
break;
default:
break;
}
}
update(amount, dueDate, amountPercentageAppliedTo, numberOfRepayments, BigDecimal.ZERO);
}
public Map<String, Object> update(final JsonCommand command, final BigDecimal amount) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(7);
final String dateFormatAsInput = command.dateFormat();
final String localeAsInput = command.locale();
final String dueDateParamName = "dueDate";
if (command.isChangeInLocalDateParameterNamed(dueDateParamName, getDueLocalDate())) {
final String valueAsInput = command.stringValueOfParameterNamed(dueDateParamName);
actualChanges.put(dueDateParamName, valueAsInput);
actualChanges.put("dateFormat", dateFormatAsInput);
actualChanges.put("locale", localeAsInput);
this.dueDate = command.localDateValueOfParameterNamed(dueDateParamName);
}
final String amountParamName = "amount";
if (command.isChangeInBigDecimalParameterNamed(amountParamName, this.amount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(amountParamName);
BigDecimal loanCharge = null;
actualChanges.put(amountParamName, newValue);
actualChanges.put("locale", localeAsInput);
switch (ChargeCalculationType.fromInt(this.chargeCalculation)) {
case INVALID:
break;
case FLAT:
if (isInstalmentFee()) {
this.amount = newValue.multiply(BigDecimal.valueOf(this.loan.fetchNumberOfInstallmensAfterExceptions()));
} else {
this.amount = newValue;
}
this.amountOutstanding = calculateOutstanding();
break;
case PERCENT_OF_AMOUNT:
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:
this.percentage = newValue;
this.amountPercentageAppliedTo = amount;
loanCharge = BigDecimal.ZERO;
if (isInstalmentFee()) {
loanCharge = calculatePerInstallmentChargeAmount(loan, ChargeCalculationType.fromInt(this.chargeCalculation),
this.percentage);
}
if (loanCharge.compareTo(BigDecimal.ZERO) == 0) {
loanCharge = percentageOf(this.amountPercentageAppliedTo);
}
this.amount = minimumAndMaximumCap(loanCharge);
this.amountOutstanding = calculateOutstanding();
break;
}
this.amountOrPercentage = newValue;
if (isInstalmentFee()) {
updateInstallmentCharges();
}
}
return actualChanges;
}
public static BigDecimal calculatePerInstallmentChargeAmount(final Loan loan, final ChargeCalculationType calculationType,
final BigDecimal percentage) {
Money amount = Money.zero(loan.getCurrency());
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
amount = amount.plus(calculateInstallmentChargeAmount(loan, calculationType, percentage, installment));
}
return amount.getAmount();
}
public BigDecimal calculatePerInstallmentChargeAmount() {
Loan loan = this.loan;
Money amount = Money.zero(loan.getCurrency());
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
amount = amount.plus(calculateInstallmentChargeAmount(loan, getChargeCalculation(), getPercentage(), installment));
}
return amount.getAmount();
}
private List<LoanInstallmentCharge> generateInstallmentLoanCharges(final LoanCharge loanCharge) {
Loan loan = this.loan;
final List<LoanInstallmentCharge> loanChargePerInstallments = new ArrayList<>();
if (loanCharge.isInstalmentFee()) {
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
if (installment.isRecalculatedInterestComponent()) {
continue;
}
BigDecimal amount;
if (loanCharge.getChargeCalculation().isFlat()) {
amount = loanCharge.amountOrPercentage();
} else {
amount = calculateInstallmentChargeAmount(loan, loanCharge.getChargeCalculation(), loanCharge.getPercentage(),
installment).getAmount();
}
final LoanInstallmentCharge loanInstallmentCharge = new LoanInstallmentCharge(amount, loanCharge, installment);
installment.getInstallmentCharges().add(loanInstallmentCharge);
loanChargePerInstallments.add(loanInstallmentCharge);
}
}
return loanChargePerInstallments;
}
private static Money calculateInstallmentChargeAmount(final Loan loan, final ChargeCalculationType calculationType,
final BigDecimal percentage, final LoanRepaymentScheduleInstallment installment) {
Money percentOf = switch (calculationType) {
case PERCENT_OF_AMOUNT -> installment.getPrincipal(loan.getCurrency());
case PERCENT_OF_AMOUNT_AND_INTEREST ->
installment.getPrincipal(loan.getCurrency()).plus(installment.getInterestCharged(loan.getCurrency()));
case PERCENT_OF_INTEREST -> installment.getInterestCharged(loan.getCurrency());
case PERCENT_OF_DISBURSEMENT_AMOUNT, INVALID, FLAT -> Money.zero(loan.getCurrency());
};
return Money.zero(loan.getCurrency()) //
.plus(LoanCharge.percentageOf(percentOf.getAmount(), percentage));
}
private void updateInstallmentCharges() {
final Collection<LoanInstallmentCharge> remove = new HashSet<>();
final List<LoanInstallmentCharge> newChargeInstallments = generateInstallmentLoanCharges(this);
if (this.loanInstallmentCharge.isEmpty()) {
this.loanInstallmentCharge.addAll(newChargeInstallments);
} else {
int index = 0;
final List<LoanInstallmentCharge> oldChargeInstallments = new ArrayList<>();
if (this.loanInstallmentCharge != null && !this.loanInstallmentCharge.isEmpty()) {
oldChargeInstallments.addAll(this.loanInstallmentCharge);
}
Collections.sort(oldChargeInstallments);
final LoanInstallmentCharge[] loanChargePerInstallmentArray = newChargeInstallments
.toArray(new LoanInstallmentCharge[newChargeInstallments.size()]);
for (final LoanInstallmentCharge chargePerInstallment : oldChargeInstallments) {
if (index == loanChargePerInstallmentArray.length) {
remove.add(chargePerInstallment);
chargePerInstallment.getInstallment().getInstallmentCharges().remove(chargePerInstallment);
} else {
LoanInstallmentCharge newLoanInstallmentCharge = loanChargePerInstallmentArray[index++];
newLoanInstallmentCharge.getInstallment().getInstallmentCharges().remove(newLoanInstallmentCharge);
chargePerInstallment.copyFrom(newLoanInstallmentCharge);
}
}
this.loanInstallmentCharge.removeAll(remove);
while (index < loanChargePerInstallmentArray.length) {
this.loanInstallmentCharge.add(loanChargePerInstallmentArray[index++]);
}
}
Money amount = Money.zero(this.loan.getCurrency());
for (LoanInstallmentCharge charge : this.loanInstallmentCharge) {
amount = amount.plus(charge.getAmount());
}
this.amount = amount.getAmount();
}
public boolean isDueAtDisbursement() {
return ChargeTimeType.fromInt(this.chargeTime).equals(ChargeTimeType.DISBURSEMENT)
|| ChargeTimeType.fromInt(this.chargeTime).equals(ChargeTimeType.TRANCHE_DISBURSEMENT);
@@ -600,7 +261,7 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return BigDecimal.ZERO.compareTo(calculateOutstanding()) == 0;
}
private BigDecimal calculateOutstanding() {
public BigDecimal calculateOutstanding() {
if (this.amount == null) {
return null;
}
@@ -645,7 +306,7 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
* @returns a minimum cap or maximum cap set on charges if the criteria fits else it returns the percentageOf if the
* amount is within min and max cap
*/
private BigDecimal minimumAndMaximumCap(final BigDecimal percentageOf) {
public BigDecimal minimumAndMaximumCap(final BigDecimal percentageOf) {
BigDecimal minMaxCap;
if (this.minCap != null) {
final int minimumCap = percentageOf.compareTo(this.minCap);
@@ -817,11 +478,6 @@ public class LoanCharge extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return ChargeCalculationType.fromInt(this.chargeCalculation);
}
public void updateAmount(final BigDecimal amount) {
this.amount = amount;
calculateOutstanding();
}
public LoanInstallmentCharge getUnpaidInstallmentLoanCharge() {
LoanInstallmentCharge unpaidChargePerInstallment = null;
for (final LoanInstallmentCharge loanChargePerInstallment : this.loanInstallmentCharge) {
@@ -925,4 +925,5 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
public void updateTransactionDate(final LocalDate transactionDate) {
this.dateOf = transactionDate;
}
}
@@ -70,4 +70,32 @@ public interface LoanTransactionRepository extends JpaRepository<LoanTransaction
@Query(FIND_LOAN_ID_BY_ID)
Optional<Long> findLoanIdById(@Param("id") Long id);
@Query("""
SELECT CASE WHEN COUNT(lt) > 0 THEN false ELSE true END
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.dateOf > :transactionDate
""")
boolean isChronologicallyLatest(@Param("transactionDate") LocalDate transactionDate, @Param("loan") Loan loan);
@Query("""
SELECT MAX(lt.dateOf) FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf NOT IN (
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CONTRA,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.MARKED_FOR_RESCHEDULING,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL_ADJUSTMENT,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.APPROVE_TRANSFER,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.INITIATE_TRANSFER,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.REJECT_TRANSFER,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.WITHDRAW_TRANSFER,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION
)
""")
Optional<LocalDate> findLastTransactionDateForReprocessing(@Param("loan") Loan loan);
}
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.springframework.util.CollectionUtils;
/**
@@ -76,8 +77,9 @@ import org.springframework.util.CollectionUtils;
public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implements LoanRepaymentScheduleTransactionProcessor {
protected final SingleLoanChargeRepaymentScheduleProcessingWrapper loanChargeProcessor = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
protected final LoanChargeValidator loanChargeValidator = new LoanChargeValidator();
protected final ExternalIdFactory externalIdFactory;
protected final LoanChargeValidator loanChargeValidator;
protected final LoanBalanceService loanBalanceService;
@Override
public boolean accept(String s) {
@@ -256,7 +258,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
} else {
loanTransaction.updateComponentsAndTotal(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
final Loan loan = loanTransaction.getLoan();
if ((loan.isClosedObligationsMet() || loan.isOverPaid()) && currentInstallment.isObligationsMet()
if ((loan.isClosedObligationsMet() || loanBalanceService.isOverPaid(loan)) && currentInstallment.isObligationsMet()
&& currentInstallment.isTransactionDateWithinPeriod(currentInstallment.getObligationsMetOnDate())) {
loanTransaction.updateTransactionDate(currentInstallment.getObligationsMetOnDate());
}
@@ -30,6 +30,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* Creocore style {@link LoanRepaymentScheduleTransactionProcessor}.
@@ -47,8 +49,9 @@ public class CreocoreLoanRepaymentScheduleTransactionProcessor extends AbstractL
public static final String STRATEGY_NAME = "Creocore Unique";
public CreocoreLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public CreocoreLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -32,6 +32,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* `First due/late charges, interest, principal, after in advance principal, charges, interest` style
@@ -48,8 +50,9 @@ public class DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactio
public static final String STRATEGY_NAME = "Due penalty, fee, interest, principal, In advance principal, penalty, fee, interest";
public DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -32,6 +32,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* `First due/late penalty, interest, principal, fee, after in advance penalty, interest, principal, fee` style
@@ -48,8 +50,9 @@ public class DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactio
public static final String STRATEGY_NAME = "Due penalty, interest, principal, fee, In advance penalty, interest, principal, fee";
public DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -30,6 +30,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* This {@link LoanRepaymentScheduleTransactionProcessor} defaults to having the payment order of Interest first, then
@@ -41,8 +43,9 @@ public class EarlyPaymentLoanRepaymentScheduleTransactionProcessor extends Abstr
public static final String STRATEGY_NAME = "Early Repayment Strategy";
public EarlyPaymentLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public EarlyPaymentLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -30,6 +30,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* Old style {@link LoanRepaymentScheduleTransactionProcessor}.
@@ -47,8 +49,9 @@ public class FineractStyleLoanRepaymentScheduleTransactionProcessor extends Abst
public static final String STRATEGY_NAME = "Penalties, Fees, Interest, Principal order";
public FineractStyleLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public FineractStyleLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -31,6 +31,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* Heavensfamily style {@link LoanRepaymentScheduleTransactionProcessor}.
@@ -49,8 +51,9 @@ public class HeavensFamilyLoanRepaymentScheduleTransactionProcessor extends Abst
public static final String STRATEGY_NAME = "HeavensFamily Unique";
public HeavensFamilyLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public HeavensFamilyLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -30,6 +30,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* This {@link LoanRepaymentScheduleTransactionProcessor} defaults to having the payment order of Interest first, then
@@ -42,8 +44,9 @@ public class InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionPr
public static final String STRATEGY_NAME = "Interest, Principal, Penalties, Fees Order";
public InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -30,6 +30,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* This {@link LoanRepaymentScheduleTransactionProcessor} defaults to having the payment order of principal first, then
@@ -42,8 +44,9 @@ public class PrincipalInterestPenaltyFeesOrderLoanRepaymentScheduleTransactionPr
public static final String STRATEGY_NAME = "Principal, Interest, Penalties, Fees Order";
public PrincipalInterestPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public PrincipalInterestPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -31,6 +31,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* Adhikar/RBI style {@link LoanRepaymentScheduleTransactionProcessor}.
@@ -51,8 +53,9 @@ public class RBILoanRepaymentScheduleTransactionProcessor extends AbstractLoanRe
public static final String STRATEGY_NAME = "Overdue/Due Fee/Int,Principal";
public RBILoanRepaymentScheduleTransactionProcessor(ExternalIdFactory externalIdFactory) {
super(externalIdFactory);
public RBILoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
@@ -35,6 +35,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
@@ -61,10 +62,14 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleP
import org.apache.fineract.portfolio.loanaccount.loanschedule.exception.MultiDisbursementEmiAmountException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.exception.MultiDisbursementOutstandingAmoutException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.exception.ScheduleDateException;
import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionService;
import org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDateType;
@RequiredArgsConstructor
public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanScheduleGenerator {
private final LoanTransactionService loanTransactionService;
@Override
public LoanScheduleModel generate(final MathContext mc, final LoanApplicationTerms loanApplicationTerms,
final Set<LoanCharge> loanCharges, final HolidayDetailDTO holidayDetailDTO) {
@@ -2796,7 +2801,7 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
LoanScheduleDTO loanScheduleDTO = rescheduleNextInstallments(mc, loanApplicationTerms, loan, holidayDetailDTO,
loanRepaymentScheduleTransactionProcessor, onDate, calculateTill);
List<LoanTransaction> loanTransactions = loan.retrieveListOfTransactionsForReprocessing();
final List<LoanTransaction> loanTransactions = loanTransactionService.retrieveListOfTransactionsForReprocessing(loan);
loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(loanApplicationTerms.getExpectedDisbursementDate(),
loanTransactions, currency, loanScheduleDTO.getInstallments(), loan.getActiveCharges());
@@ -25,10 +25,10 @@ import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionService;
import org.apache.fineract.portfolio.loanproduct.domain.AmortizationMethod;
import org.springframework.stereotype.Component;
@@ -56,12 +56,19 @@ import org.springframework.stereotype.Component;
* </p>
*/
@Component
@RequiredArgsConstructor
public class CumulativeDecliningBalanceInterestLoanScheduleGenerator extends AbstractCumulativeLoanScheduleGenerator {
private final ScheduledDateGenerator scheduledDateGenerator;
private final PaymentPeriodsInOneYearCalculator paymentPeriodsInOneYearCalculator;
public CumulativeDecliningBalanceInterestLoanScheduleGenerator(final ScheduledDateGenerator scheduledDateGenerator,
final PaymentPeriodsInOneYearCalculator paymentPeriodsInOneYearCalculator,
final LoanTransactionService loanTransactionService) {
super(loanTransactionService);
this.scheduledDateGenerator = scheduledDateGenerator;
this.paymentPeriodsInOneYearCalculator = paymentPeriodsInOneYearCalculator;
}
@Override
public ScheduledDateGenerator getScheduledDateGenerator() {
return scheduledDateGenerator;
@@ -24,18 +24,25 @@ import java.time.LocalDate;
import java.util.Collection;
import java.util.Map;
import java.util.TreeMap;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionService;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class CumulativeFlatInterestLoanScheduleGenerator extends AbstractCumulativeLoanScheduleGenerator {
private final ScheduledDateGenerator scheduledDateGenerator;
private final PaymentPeriodsInOneYearCalculator paymentPeriodsInOneYearCalculator;
public CumulativeFlatInterestLoanScheduleGenerator(final ScheduledDateGenerator scheduledDateGenerator,
final PaymentPeriodsInOneYearCalculator paymentPeriodsInOneYearCalculator,
final LoanTransactionService loanTransactionService) {
super(loanTransactionService);
this.scheduledDateGenerator = scheduledDateGenerator;
this.paymentPeriodsInOneYearCalculator = paymentPeriodsInOneYearCalculator;
}
@Override
public ScheduledDateGenerator getScheduledDateGenerator() {
return scheduledDateGenerator;
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.loanaccount.serialization;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.organisation.holiday.domain.Holiday;
@@ -31,11 +32,15 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.exception.LoanApplicationDateException;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public final class LoanDownPaymentTransactionValidator {
private final LoanBalanceService loanBalanceService;
public void validateRepaymentDateIsOnNonWorkingDay(final LocalDate repaymentDate, final WorkingDays workingDays,
final boolean allowTransactionsOnNonWorkingDay) {
if (!allowTransactionsOnNonWorkingDay && !WorkingDaysUtil.isWorkingDay(workingDays, repaymentDate)) {
@@ -53,7 +58,7 @@ public final class LoanDownPaymentTransactionValidator {
}
public void validateLoanStatusIsActiveOrFullyPaidOrOverpaid(final Loan loan) {
if (!(loan.isOpen() || loan.isClosedObligationsMet() || loan.isOverPaid())) {
if (!(loan.isOpen() || loan.isClosedObligationsMet() || loanBalanceService.isOverPaid(loan))) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final String defaultUserMessage = "Loan must be Active, Fully Paid or Overpaid";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.must.be.active.fully.paid.or.overpaid",
@@ -0,0 +1,278 @@
/**
* 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 java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails;
import org.apache.fineract.portfolio.loanaccount.domain.LoanInstallmentCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleProcessingWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType;
import org.springframework.stereotype.Service;
@Service
public class LoanBalanceService {
public Money calculateTotalOverpayment(final Loan loan) {
Money totalPaidInRepayments = loan.getTotalPaidInRepayments();
final MonetaryCurrency currency = loan.getCurrency();
Money cumulativeTotalPaidOnInstallments = Money.zero(currency);
Money cumulativeTotalWaivedOnInstallments = Money.zero(currency);
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment scheduledRepayment : installments) {
cumulativeTotalPaidOnInstallments = cumulativeTotalPaidOnInstallments
.plus(scheduledRepayment.getPrincipalCompleted(currency).plus(scheduledRepayment.getInterestPaid(currency)))
.plus(scheduledRepayment.getFeeChargesPaid(currency)).plus(scheduledRepayment.getPenaltyChargesPaid(currency));
cumulativeTotalWaivedOnInstallments = cumulativeTotalWaivedOnInstallments.plus(scheduledRepayment.getInterestWaived(currency));
}
for (final LoanTransaction loanTransaction : loan.getLoanTransactions()) {
if (loanTransaction.isReversed()) {
continue;
}
if (loanTransaction.isRefund() || loanTransaction.isRefundForActiveLoan()) {
totalPaidInRepayments = totalPaidInRepayments.minus(loanTransaction.getAmount(currency));
} else if (loanTransaction.isCreditBalanceRefund()) {
if (loanTransaction.getPrincipalPortion(currency).isZero()) {
totalPaidInRepayments = totalPaidInRepayments.minus(loanTransaction.getOverPaymentPortion(currency));
}
} else if (loanTransaction.isChargeback()) {
if (loanTransaction.getPrincipalPortion(currency).isZero() && loan.getCreditAllocationRules().stream() //
.filter(car -> car.getTransactionType().equals(CreditAllocationTransactionType.CHARGEBACK)) //
.findAny() //
.isEmpty()) {
totalPaidInRepayments = totalPaidInRepayments.minus(loanTransaction.getOverPaymentPortion(currency));
}
}
}
// if total paid in transactions doesn't match repayment schedule then there's an overpayment.
return totalPaidInRepayments.minus(cumulativeTotalPaidOnInstallments);
}
public boolean isOverPaid(final Loan loan) {
return calculateTotalOverpayment(loan).isGreaterThanZero();
}
public void updateLoanSummaryDerivedFields(final Loan loan) {
if (loan.isNotDisbursed()) {
if (loan.getSummary() != null) {
loan.getSummary().zeroFields();
}
loan.setTotalOverpaid(null);
} else {
final Money overpaidBy = calculateTotalOverpayment(loan);
loan.setTotalOverpaid(null);
if (!overpaidBy.isLessThanZero()) {
loan.setTotalOverpaid(overpaidBy.getAmountDefaultedToNullIfZero());
}
final Money recoveredAmount = loan.calculateTotalRecoveredPayments();
loan.setTotalRecovered(recoveredAmount.getAmountDefaultedToNullIfZero());
final Money principal = loan.getLoanRepaymentScheduleDetail().getPrincipal();
loan.getSummary().updateSummary(loan.getCurrency(), principal, loan.getRepaymentScheduleInstallments(), loan.getLoanCharges());
loan.updateLoanOutstandingBalances();
}
}
public void updateLoanToLastDisbursalState(final Loan loan, final LoanDisbursementDetails disbursementDetail) {
for (final LoanCharge charge : loan.getActiveCharges()) {
if (charge.isOverdueInstallmentCharge()) {
charge.setActive(false);
} else if (charge.isTrancheDisbursementCharge() && disbursementDetail.getDisbursementDate()
.equals(charge.getTrancheDisbursementCharge().getloanDisbursementDetails().actualDisbursementDate())) {
charge.resetToOriginal(loan.getCurrency());
}
}
loan.getLoanRepaymentScheduleDetail().setPrincipal(loan.getDisbursedAmount().subtract(disbursementDetail.principal()));
disbursementDetail.updateActualDisbursementDate(null);
disbursementDetail.reverse();
updateLoanSummaryDerivedFields(loan);
}
public Money getReceivableInterest(final Loan loan, final LocalDate tillDate) {
Money receivableInterest = Money.zero(loan.getCurrency());
for (final LoanTransaction transaction : loan.getLoanTransactions()) {
if (transaction.isNotReversed() && !transaction.isRepaymentAtDisbursement() && !transaction.isDisbursement()
&& !DateUtils.isAfter(transaction.getTransactionDate(), tillDate)) {
if (transaction.isAccrual()) {
receivableInterest = receivableInterest.plus(transaction.getInterestPortion(loan.getCurrency()));
} else if (transaction.isRepaymentLikeType() || transaction.isInterestWaiver() || transaction.isAccrualAdjustment()) {
receivableInterest = receivableInterest.minus(transaction.getInterestPortion(loan.getCurrency()));
}
}
if (receivableInterest.isLessThanZero()) {
receivableInterest = receivableInterest.zero();
}
}
return receivableInterest;
}
public LoanRepaymentScheduleInstallment fetchLoanForeclosureDetail(final Loan loan, final LocalDate closureDate) {
Money[] receivables = retrieveIncomeOutstandingTillDate(loan, closureDate);
Money totalPrincipal = Money.of(loan.getCurrency(), loan.getSummary().getTotalPrincipalOutstanding());
totalPrincipal = totalPrincipal.minus(receivables[3]);
final LocalDate currentDate = DateUtils.getBusinessLocalDate();
return new LoanRepaymentScheduleInstallment(null, 0, currentDate, currentDate, totalPrincipal.getAmount(),
receivables[0].getAmount(), receivables[1].getAmount(), receivables[2].getAmount(), false, null);
}
public Money[] retrieveIncomeForOverlappingPeriod(final Loan loan, final LocalDate paymentDate) {
Money[] balances = new Money[3];
final MonetaryCurrency currency = loan.getCurrency();
balances[0] = balances[1] = balances[2] = Money.zero(currency);
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(loan.getRepaymentScheduleInstallments());
for (final LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) {
boolean isFirstNormalInstallment = installment.getInstallmentNumber().equals(firstNormalInstallmentNumber);
if (DateUtils.isEqual(paymentDate, installment.getDueDate())) {
Money interest = installment.getInterestCharged(currency);
Money fee = installment.getFeeChargesCharged(currency);
Money penalty = installment.getPenaltyChargesCharged(currency);
balances[0] = interest;
balances[1] = fee;
balances[2] = penalty;
break;
} else if (DateUtils.isDateInRangeExclusive(paymentDate, installment.getFromDate(), installment.getDueDate())) {
balances = fetchInterestFeeAndPenaltyTillDate(loan, paymentDate, currency, installment, isFirstNormalInstallment);
break;
}
}
return balances;
}
private Money[] retrieveIncomeOutstandingTillDate(final Loan loan, final LocalDate paymentDate) {
Money[] balances = new Money[4];
final MonetaryCurrency currency = loan.getCurrency();
Money interest = Money.zero(currency);
Money paidFromFutureInstallments = Money.zero(currency);
Money fee = Money.zero(currency);
Money penalty = Money.zero(currency);
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(loan.getRepaymentScheduleInstallments());
for (final LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) {
boolean isFirstNormalInstallment = installment.getInstallmentNumber().equals(firstNormalInstallmentNumber);
if (!DateUtils.isBefore(paymentDate, installment.getDueDate())) {
interest = interest.plus(installment.getInterestOutstanding(currency));
penalty = penalty.plus(installment.getPenaltyChargesOutstanding(currency));
fee = fee.plus(installment.getFeeChargesOutstanding(currency));
} else if (DateUtils.isAfter(paymentDate, installment.getFromDate())) {
Money[] balancesForCurrentPeriod = fetchInterestFeeAndPenaltyTillDate(loan, paymentDate, currency, installment,
isFirstNormalInstallment);
if (balancesForCurrentPeriod[0].isGreaterThan(balancesForCurrentPeriod[5])) {
interest = interest.plus(balancesForCurrentPeriod[0]).minus(balancesForCurrentPeriod[5]);
} else {
paidFromFutureInstallments = paidFromFutureInstallments.plus(balancesForCurrentPeriod[5])
.minus(balancesForCurrentPeriod[0]);
}
if (balancesForCurrentPeriod[1].isGreaterThan(balancesForCurrentPeriod[3])) {
fee = fee.plus(balancesForCurrentPeriod[1].minus(balancesForCurrentPeriod[3]));
} else {
paidFromFutureInstallments = paidFromFutureInstallments
.plus(balancesForCurrentPeriod[3].minus(balancesForCurrentPeriod[1]));
}
if (balancesForCurrentPeriod[2].isGreaterThan(balancesForCurrentPeriod[4])) {
penalty = penalty.plus(balancesForCurrentPeriod[2].minus(balancesForCurrentPeriod[4]));
} else {
paidFromFutureInstallments = paidFromFutureInstallments.plus(balancesForCurrentPeriod[4])
.minus(balancesForCurrentPeriod[2]);
}
} else {
paidFromFutureInstallments = paidFromFutureInstallments.plus(installment.getInterestPaid(currency))
.plus(installment.getPenaltyChargesPaid(currency)).plus(installment.getFeeChargesPaid(currency));
}
}
balances[0] = interest;
balances[1] = fee;
balances[2] = penalty;
balances[3] = paidFromFutureInstallments;
return balances;
}
private Money[] fetchInterestFeeAndPenaltyTillDate(final Loan loan, final LocalDate paymentDate, final MonetaryCurrency currency,
final LoanRepaymentScheduleInstallment installment, final boolean isFirstNormalInstallment) {
Money penaltyForCurrentPeriod = Money.zero(loan.getCurrency());
Money penaltyAccoutedForCurrentPeriod = Money.zero(loan.getCurrency());
Money feeForCurrentPeriod = Money.zero(loan.getCurrency());
Money feeAccountedForCurrentPeriod = Money.zero(loan.getCurrency());
int totalPeriodDays = DateUtils.getExactDifferenceInDays(installment.getFromDate(), installment.getDueDate());
int tillDays = DateUtils.getExactDifferenceInDays(installment.getFromDate(), paymentDate);
Money interestForCurrentPeriod = Money.of(loan.getCurrency(), BigDecimal.valueOf(
calculateInterestForDays(totalPeriodDays, installment.getInterestCharged(loan.getCurrency()).getAmount(), tillDays)));
Money interestAccountedForCurrentPeriod = installment.getInterestWaived(loan.getCurrency())
.plus(installment.getInterestPaid(loan.getCurrency()));
for (LoanCharge loanCharge : loan.getLoanCharges()) {
if (loanCharge.isActive() && !loanCharge.isDueAtDisbursement()) {
boolean isDue = loanCharge.isDueInPeriod(installment.getFromDate(), paymentDate, isFirstNormalInstallment);
if (isDue) {
if (loanCharge.isPenaltyCharge()) {
penaltyForCurrentPeriod = penaltyForCurrentPeriod.plus(loanCharge.getAmount(loan.getCurrency()));
penaltyAccoutedForCurrentPeriod = penaltyAccoutedForCurrentPeriod
.plus(loanCharge.getAmountWaived(loan.getCurrency()).plus(loanCharge.getAmountPaid(loan.getCurrency())));
} else {
feeForCurrentPeriod = feeForCurrentPeriod.plus(loanCharge.getAmount(currency));
feeAccountedForCurrentPeriod = feeAccountedForCurrentPeriod
.plus(loanCharge.getAmountWaived(loan.getCurrency()).plus(
loanCharge.getAmountPaid(loan.getCurrency())));
}
} else if (loanCharge.isInstalmentFee()) {
LoanInstallmentCharge loanInstallmentCharge = loanCharge.getInstallmentLoanCharge(installment.getInstallmentNumber());
if (loanCharge.isPenaltyCharge()) {
penaltyAccoutedForCurrentPeriod = penaltyAccoutedForCurrentPeriod
.plus(loanInstallmentCharge.getAmountPaid(currency));
} else {
feeAccountedForCurrentPeriod = feeAccountedForCurrentPeriod.plus(loanInstallmentCharge.getAmountPaid(currency));
}
}
}
}
Money[] balances = new Money[6];
balances[0] = interestForCurrentPeriod;
balances[1] = feeForCurrentPeriod;
balances[2] = penaltyForCurrentPeriod;
balances[3] = feeAccountedForCurrentPeriod;
balances[4] = penaltyAccoutedForCurrentPeriod;
balances[5] = interestAccountedForCurrentPeriod;
return balances;
}
private double calculateInterestForDays(final int daysInPeriod, final BigDecimal interest, final int days) {
if (interest.doubleValue() == 0) {
return 0;
}
return interest.doubleValue() / daysInPeriod * days;
}
}
@@ -21,20 +21,30 @@ package org.apache.fineract.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.service.TemporaryConfigurationServiceContainer;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.apache.fineract.portfolio.charge.exception.LoanChargeWithoutMandatoryFieldException;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy;
import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails;
import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent;
import org.apache.fineract.portfolio.loanaccount.domain.LoanInstallmentCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
import org.apache.fineract.portfolio.loanaccount.domain.LoanOverdueInstallmentCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
@@ -52,6 +62,7 @@ public class LoanChargeService {
private final LoanChargeValidator loanChargeValidator;
private final LoanTransactionProcessingService loanTransactionProcessingService;
private final LoanLifecycleStateMachine loanLifecycleStateMachine;
private final LoanBalanceService loanBalanceService;
public void recalculateAllCharges(final Loan loan) {
Set<LoanCharge> charges = loan.getActiveCharges();
@@ -68,54 +79,25 @@ public class LoanChargeService {
BigDecimal totalChargeAmt = BigDecimal.ZERO;
if (loanCharge.getChargeCalculation().isPercentageBased()) {
if (loanCharge.isOverdueInstallmentCharge()) {
amount = loan.calculateOverdueAmountPercentageAppliedTo(loanCharge, penaltyWaitPeriod);
amount = calculateOverdueAmountPercentageAppliedTo(loan, loanCharge, penaltyWaitPeriod);
} else {
amount = calculateAmountPercentageAppliedTo(loan, loanCharge);
}
chargeAmt = loanCharge.getPercentage();
if (loanCharge.isInstalmentFee()) {
totalChargeAmt = calculatePerInstallmentChargeAmount(loanCharge);
totalChargeAmt = calculatePerInstallmentChargeAmount(loan, loanCharge);
}
} else {
chargeAmt = loanCharge.amountOrPercentage();
}
if (loanCharge.isActive()) {
clearLoanInstallmentChargesBeforeRegeneration(loanCharge);
loanCharge.update(chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(),
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(),
totalChargeAmt);
loanChargeValidator.validateChargeHasValidSpecifiedDateIfApplicable(loan, loanCharge, loan.getDisbursementDate());
}
}
private void clearLoanInstallmentChargesBeforeRegeneration(final LoanCharge loanCharge) {
/*
* JW https://issues.apache.org/jira/browse/FINERACT-1557 For loan installment charges only : Clear down
* installment charges from the loanCharge and from each of the repayment installments and allow them to be
* recalculated fully anew. This patch is to avoid the 'merging' of existing and regenerated installment charges
* which results in the installment charges being deleted on loan approval if the schedule is regenerated. Not
* pretty. updateInstallmentCharges in LoanCharge.java: the merging looks like it will work but doesn't so this
* patch simply hits the part which 'adds all' rather than merge. Possibly an ORM issue. The issue could be to
* do with the fact that, on approval, the "recalculateLoanCharge" happens twice (probably 2 schedule
* regenerations) whereas it only happens once on Submit and Disburse (and no problems with them)
*
* if (this.loanInstallmentCharge.isEmpty()) { this.loanInstallmentCharge.addAll(newChargeInstallments);
*/
Loan loan = loanCharge.getLoan();
if (!loan.isSubmittedAndPendingApproval() && !loan.isApproved()) {
return;
} // doing for both just in case status is not
// updated at this points
if (loanCharge.isInstalmentFee()) {
loanCharge.clearLoanInstallmentCharges();
for (final LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) {
if (installment.isRecalculatedInterestComponent()) {
continue; // JW: does this in generateInstallmentLoanCharges - but don't understand it
}
installment.getInstallmentCharges().clear();
}
}
}
public void makeChargePayment(final Loan loan, final Long chargeId, final List<Long> existingTransactionIds,
final List<Long> existingReversedTransactionIds, final LoanTransaction paymentTransaction, final Integer installmentNumber) {
loanChargeValidator.validateChargePaymentNotInFuture(paymentTransaction);
@@ -130,36 +112,6 @@ public class LoanChargeService {
handleChargePaidTransaction(loan, charge, paymentTransaction, installmentNumber);
}
public void handleChargePaidTransaction(final Loan loan, final LoanCharge charge, final LoanTransaction chargesPayment,
final Integer installmentNumber) {
chargesPayment.updateLoan(loan);
final LoanChargePaidBy loanChargePaidBy = new LoanChargePaidBy(chargesPayment, charge,
chargesPayment.getAmount(loan.getCurrency()).getAmount(), installmentNumber);
chargesPayment.getLoanChargesPaid().add(loanChargePaidBy);
loan.addLoanTransaction(chargesPayment);
loanLifecycleStateMachine.transition(LoanEvent.LOAN_CHARGE_PAYMENT, loan);
final List<LoanRepaymentScheduleInstallment> chargePaymentInstallments = new ArrayList<>();
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(loan.getRepaymentScheduleInstallments());
for (final LoanRepaymentScheduleInstallment installment : installments) {
boolean isFirstInstallment = installment.getInstallmentNumber().equals(firstNormalInstallmentNumber);
if (installment.getInstallmentNumber().equals(installmentNumber) || (installmentNumber == null
&& charge.isDueInPeriod(installment.getFromDate(), installment.getDueDate(), isFirstInstallment))) {
chargePaymentInstallments.add(installment);
break;
}
}
final Set<LoanCharge> loanCharges = new HashSet<>(1);
loanCharges.add(charge);
loanTransactionProcessingService.processLatestTransaction(loan.getTransactionProcessingStrategyCode(), chargesPayment,
new TransactionCtx(loan.getCurrency(), chargePaymentInstallments, loanCharges,
new MoneyHolder(loan.getTotalOverpaidAsMoney()), null));
loanLifecycleStateMachine.determineAndTransition(loan, chargesPayment.getTransactionDate());
}
/**
* Creates a loanTransaction for "Apply Charge Event" with transaction date set to "suppliedTransactionDate". The
* newly created transaction is also added to the Loan on which this method is called.
@@ -230,14 +182,14 @@ public class LoanChargeService {
if (loanCharge.getChargeCalculation().isPercentageBased()) {
chargeAmt = loanCharge.getPercentage();
if (loanCharge.isInstalmentFee()) {
totalChargeAmt = calculatePerInstallmentChargeAmount(loanCharge);
totalChargeAmt = calculatePerInstallmentChargeAmount(loan, loanCharge);
} else if (loanCharge.isOverdueInstallmentCharge()) {
totalChargeAmt = loanCharge.amountOutstanding();
}
} else {
chargeAmt = loanCharge.amountOrPercentage();
}
loanCharge.update(chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(), totalChargeAmt);
update(loanCharge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(), totalChargeAmt);
// NOTE: must add new loan charge to set of loan charges before
// reprocessing the repayment schedule.
@@ -250,72 +202,11 @@ public class LoanChargeService {
// store Id's of existing loan transactions and existing reversed loan transactions
final SingleLoanChargeRepaymentScheduleProcessingWrapper wrapper = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
wrapper.reprocess(loan.getCurrency(), loan.getDisbursementDate(), loan.getRepaymentScheduleInstallments(), loanCharge);
loan.updateLoanSummaryDerivedFields();
loanBalanceService.updateLoanSummaryDerivedFields(loan);
loanLifecycleStateMachine.transition(LoanEvent.LOAN_CHARGE_ADDED, loan);
}
public void updateLoanCharges(final Loan loan, final Set<LoanCharge> loanCharges) {
List<Long> existingCharges = fetchAllLoanChargeIds(loan);
/* Process new and updated charges **/
for (final LoanCharge loanCharge : loanCharges) {
LoanCharge charge = loanCharge;
// add new charges
if (loanCharge.getId() == null) {
LoanTrancheDisbursementCharge loanTrancheDisbursementCharge;
loanCharge.update(loan);
if (loan.getLoanProduct().isMultiDisburseLoan() && loanCharge.isTrancheDisbursementCharge()) {
loanCharge.getTrancheDisbursementCharge().getloanDisbursementDetails().updateLoan(loan);
for (final LoanDisbursementDetails loanDisbursementDetails : loan.getDisbursementDetails()) {
if (loanCharge.getTrancheDisbursementCharge().getloanDisbursementDetails().getId() == null
&& loanCharge.getTrancheDisbursementCharge().getloanDisbursementDetails().equals(loanDisbursementDetails)) {
loanTrancheDisbursementCharge = new LoanTrancheDisbursementCharge(loanCharge, loanDisbursementDetails);
loanCharge.updateLoanTrancheDisbursementCharge(loanTrancheDisbursementCharge);
}
}
}
loan.addCharge(loanCharge);
} else {
charge = fetchLoanChargesById(loan, charge.getId());
if (charge != null) {
existingCharges.remove(charge.getId());
}
}
final BigDecimal amount = calculateAmountPercentageAppliedTo(loan, loanCharge);
BigDecimal chargeAmt;
BigDecimal totalChargeAmt = BigDecimal.ZERO;
if (loanCharge.getChargeCalculation().isPercentageBased()) {
chargeAmt = loanCharge.getPercentage();
if (loanCharge.isInstalmentFee()) {
totalChargeAmt = calculatePerInstallmentChargeAmount(loanCharge);
}
} else {
chargeAmt = loanCharge.amountOrPercentage();
}
if (charge != null) {
charge.update(chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(),
totalChargeAmt);
}
}
/* Updated deleted charges **/
for (Long id : existingCharges) {
fetchLoanChargesById(loan, id).setActive(false);
}
loan.updateSummaryWithTotalFeeChargesDueAtDisbursement(loan.deriveSumTotalOfChargesDueAtDisbursement());
}
private List<Long> fetchAllLoanChargeIds(final Loan loan) {
List<Long> list = new ArrayList<>();
for (LoanCharge loanCharge : loan.getCharges()) {
list.add(loanCharge.getId());
}
return list;
}
public BigDecimal calculateAmountPercentageAppliedTo(final Loan loan, final LoanCharge loanCharge) {
if (loanCharge.isOverdueInstallmentCharge()) {
return loanCharge.getAmountPercentageAppliedTo();
@@ -343,38 +234,254 @@ public class LoanChargeService {
};
}
private BigDecimal getDerivedAmountForCharge(final Loan loan, final LoanCharge loanCharge) {
BigDecimal amount = BigDecimal.ZERO;
if (loan.isMultiDisburmentLoan() && loanCharge.getCharge().getChargeTimeType().equals(ChargeTimeType.DISBURSEMENT.getValue())) {
amount = loan.getApprovedPrincipal();
} else {
// If charge type is specified due date and loan is multi disburment loan.
// Then we need to get as of this loan charge due date how much amount disbursed.
if (loanCharge.isSpecifiedDueDate() && loan.isMultiDisburmentLoan()) {
for (final LoanDisbursementDetails loanDisbursementDetails : loan.getDisbursementDetails()) {
if (!DateUtils.isAfter(loanDisbursementDetails.expectedDisbursementDate(), loanCharge.getDueDate())) {
amount = amount.add(loanDisbursementDetails.principal());
public void updateLoanCharges(final Loan loan, final Set<LoanCharge> loanCharges) {
List<Long> existingCharges = fetchAllLoanChargeIds(loan);
/* Process new and updated charges **/
for (final LoanCharge loanCharge : loanCharges) {
LoanCharge charge = loanCharge;
// add new charges
if (loanCharge.getId() == null) {
LoanTrancheDisbursementCharge loanTrancheDisbursementCharge;
loanCharge.update(loan);
if (loan.getLoanProduct().isMultiDisburseLoan() && loanCharge.isTrancheDisbursementCharge()) {
loanCharge.getTrancheDisbursementCharge().getloanDisbursementDetails().updateLoan(loan);
for (final LoanDisbursementDetails loanDisbursementDetails : loan.getDisbursementDetails()) {
if (loanCharge.getTrancheDisbursementCharge().getloanDisbursementDetails().getId() == null
&& loanCharge.getTrancheDisbursementCharge().getloanDisbursementDetails().equals(loanDisbursementDetails)) {
loanTrancheDisbursementCharge = new LoanTrancheDisbursementCharge(loanCharge, loanDisbursementDetails);
loanCharge.updateLoanTrancheDisbursementCharge(loanTrancheDisbursementCharge);
}
}
}
loan.getLoanCharges().add(loanCharge);
} else {
amount = loan.getPrincipal().getAmount();
charge = loan.fetchLoanChargesById(charge.getId());
if (charge != null) {
existingCharges.remove(charge.getId());
}
}
final BigDecimal amount = calculateAmountPercentageAppliedTo(loan, loanCharge);
BigDecimal chargeAmt;
BigDecimal totalChargeAmt = BigDecimal.ZERO;
if (loanCharge.getChargeCalculation().isPercentageBased()) {
chargeAmt = loanCharge.getPercentage();
if (loanCharge.isInstalmentFee()) {
totalChargeAmt = calculatePerInstallmentChargeAmount(loan, loanCharge);
}
} else {
chargeAmt = loanCharge.amountOrPercentage();
}
if (charge != null) {
update(charge, chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(),
totalChargeAmt);
}
}
return amount;
/* Updated deleted charges **/
for (Long id : existingCharges) {
loan.fetchLoanChargesById(id).setActive(false);
}
loan.updateSummaryWithTotalFeeChargesDueAtDisbursement(loan.deriveSumTotalOfChargesDueAtDisbursement());
}
private Money getTotalAllTrancheDisbursementAmount(final Loan loan) {
public BigDecimal calculatePerInstallmentChargeAmount(final Loan loan, final ChargeCalculationType calculationType,
final BigDecimal percentage) {
Money amount = Money.zero(loan.getCurrency());
if (loan.isMultiDisburmentLoan()) {
for (final LoanDisbursementDetails loanDisbursementDetail : loan.getDisbursementDetails()) {
amount = amount.plus(loanDisbursementDetail.principal());
}
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
amount = amount.plus(calculateInstallmentChargeAmount(loan, calculationType, percentage, installment));
}
return amount;
return amount.getAmount();
}
private BigDecimal calculatePerInstallmentChargeAmount(final LoanCharge loanCharge) {
return loanCharge.calculatePerInstallmentChargeAmount();
public Map<String, Object> update(final JsonCommand command, final BigDecimal amount, final LoanCharge loanCharge) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(7);
final String dateFormatAsInput = command.dateFormat();
final String localeAsInput = command.locale();
final String dueDateParamName = "dueDate";
if (command.isChangeInLocalDateParameterNamed(dueDateParamName, loanCharge.getDueLocalDate())) {
final String valueAsInput = command.stringValueOfParameterNamed(dueDateParamName);
actualChanges.put(dueDateParamName, valueAsInput);
actualChanges.put("dateFormat", dateFormatAsInput);
actualChanges.put("locale", localeAsInput);
loanCharge.setDueDate(command.localDateValueOfParameterNamed(dueDateParamName));
}
final String amountParamName = "amount";
if (command.isChangeInBigDecimalParameterNamed(amountParamName, loanCharge.getAmount())) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(amountParamName);
BigDecimal loanChargeAmount;
actualChanges.put(amountParamName, newValue);
actualChanges.put("locale", localeAsInput);
switch (loanCharge.getChargeCalculation()) {
case INVALID:
break;
case FLAT:
if (loanCharge.isInstalmentFee()) {
loanCharge.setAmount(
newValue.multiply(BigDecimal.valueOf(loanCharge.getLoan().fetchNumberOfInstallmensAfterExceptions())));
} else {
loanCharge.setAmount(newValue);
}
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
break;
case PERCENT_OF_AMOUNT:
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:
loanCharge.setPercentage(newValue);
loanCharge.setAmountPercentageAppliedTo(amount);
loanChargeAmount = BigDecimal.ZERO;
if (loanCharge.isInstalmentFee()) {
loanChargeAmount = calculatePerInstallmentChargeAmount(loanCharge.getLoan(), loanCharge.getChargeCalculation(),
loanCharge.getPercentage());
}
if (loanChargeAmount.compareTo(BigDecimal.ZERO) == 0) {
loanChargeAmount = loanCharge.percentageOf(loanCharge.getAmountPercentageAppliedTo());
}
loanCharge.setAmount(loanCharge.minimumAndMaximumCap(loanChargeAmount));
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
break;
}
loanCharge.setAmountOrPercentage(newValue);
if (loanCharge.isInstalmentFee()) {
updateInstallmentCharges(loanCharge);
}
}
return actualChanges;
}
public void populateDerivedFields(final LoanCharge loanCharge, final BigDecimal amountPercentageAppliedTo,
final BigDecimal chargeAmount, Integer numberOfRepayments, BigDecimal loanChargeAmount) {
switch (loanCharge.getChargeCalculation()) {
case INVALID:
loanCharge.setPercentage(null);
loanCharge.setAmount(null);
loanCharge.setAmountPercentageAppliedTo(null);
loanCharge.setAmountPaid(null);
loanCharge.setAmountOutstanding(BigDecimal.ZERO);
loanCharge.setAmountWaived(null);
loanCharge.setAmountWrittenOff(null);
break;
case FLAT:
loanCharge.setPercentage(null);
loanCharge.setAmountPercentageAppliedTo(null);
loanCharge.setAmountPaid(null);
if (loanCharge.isInstalmentFee()) {
if (numberOfRepayments == null) {
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmensAfterExceptions();
}
loanCharge.setAmount(chargeAmount.multiply(BigDecimal.valueOf(numberOfRepayments)));
} else {
loanCharge.setAmount(chargeAmount);
}
loanCharge.setAmountOutstanding(loanCharge.getAmount());
loanCharge.setAmountWaived(null);
loanCharge.setAmountWrittenOff(null);
break;
case PERCENT_OF_AMOUNT:
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:
loanCharge.setPercentage(chargeAmount);
loanCharge.setAmountPercentageAppliedTo(amountPercentageAppliedTo);
if (loanChargeAmount.compareTo(BigDecimal.ZERO) == 0) {
loanChargeAmount = loanCharge.percentageOf(loanCharge.getAmountPercentageAppliedTo());
}
loanCharge.setAmount(loanCharge.minimumAndMaximumCap(loanChargeAmount));
loanCharge.setAmountPaid(null);
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
loanCharge.setAmountWaived(null);
loanCharge.setAmountWrittenOff(null);
break;
}
loanCharge.setAmountOrPercentage(chargeAmount);
if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) {
updateInstallmentCharges(loanCharge);
}
}
public void update(final LoanCharge loanCharge, final BigDecimal amount, final LocalDate dueDate, final Integer numberOfRepayments) {
BigDecimal amountPercentageAppliedTo = BigDecimal.ZERO;
if (loanCharge.getLoan() != null) {
switch (loanCharge.getChargeCalculation()) {
case PERCENT_OF_AMOUNT:
// If charge type is specified due date and loan is multi disburment loan.
// Then we need to get as of this loan charge due date how much amount disbursed.
if (loanCharge.getLoan().isMultiDisburmentLoan() && loanCharge.isSpecifiedDueDate()) {
for (final LoanDisbursementDetails loanDisbursementDetails : loanCharge.getLoan().getDisbursementDetails()) {
if (!DateUtils.isAfter(loanDisbursementDetails.expectedDisbursementDate(), loanCharge.getDueDate())) {
amountPercentageAppliedTo = amountPercentageAppliedTo.add(loanDisbursementDetails.principal());
}
}
} else {
amountPercentageAppliedTo = loanCharge.getLoan().getPrincipal().getAmount();
}
break;
case PERCENT_OF_AMOUNT_AND_INTEREST:
amountPercentageAppliedTo = loanCharge.getLoan().getPrincipal().getAmount()
.add(loanCharge.getLoan().getTotalInterest());
break;
case PERCENT_OF_INTEREST:
amountPercentageAppliedTo = loanCharge.getLoan().getTotalInterest();
break;
case PERCENT_OF_DISBURSEMENT_AMOUNT:
LoanTrancheDisbursementCharge loanTrancheDisbursementCharge = loanCharge.getLoanTrancheDisbursementCharge();
amountPercentageAppliedTo = loanTrancheDisbursementCharge.getloanDisbursementDetails().principal();
break;
default:
break;
}
}
update(loanCharge, amount, dueDate, amountPercentageAppliedTo, numberOfRepayments, BigDecimal.ZERO);
}
public LoanCharge create(final Loan loan, final Charge chargeDefinition, final BigDecimal loanPrincipal, final BigDecimal amount,
final ChargeTimeType chargeTime, final ChargeCalculationType chargeCalculation, final LocalDate dueDate,
final ChargePaymentMode chargePaymentMode, final Integer numberOfRepayments, final BigDecimal loanChargeAmount,
final ExternalId externalId) {
final LoanCharge loanCharge = new LoanCharge();
loanCharge.setLoan(loan);
loanCharge.setCharge(chargeDefinition);
loanCharge.setSubmittedOnDate(DateUtils.getBusinessLocalDate());
loanCharge.setPenaltyCharge(chargeDefinition.isPenalty());
loanCharge.setMinCap(chargeDefinition.getMinCap());
loanCharge.setMaxCap(chargeDefinition.getMaxCap());
loanCharge.setChargeTime(chargeTime == null ? chargeDefinition.getChargeTimeType() : chargeTime.getValue());
if (loanCharge.getChargeTimeType().equals(ChargeTimeType.SPECIFIED_DUE_DATE)
|| loanCharge.getChargeTimeType().equals(ChargeTimeType.OVERDUE_INSTALLMENT)) {
if (dueDate == null) {
final String defaultUserMessage = "Loan charge is missing due date.";
throw new LoanChargeWithoutMandatoryFieldException("loanChargeAmount", "dueDate", defaultUserMessage,
chargeDefinition.getId(), chargeDefinition.getName());
}
loanCharge.setDueDate(dueDate);
} else {
loanCharge.setDueDate(null);
}
loanCharge.setChargeCalculation(chargeCalculation == null ? chargeDefinition.getChargeCalculation() : chargeCalculation.getValue());
BigDecimal chargeAmount = chargeDefinition.getAmount();
if (amount != null) {
chargeAmount = amount;
}
loanCharge.setChargePaymentMode(chargePaymentMode == null ? chargeDefinition.getChargePaymentMode() : chargePaymentMode.getValue());
populateDerivedFields(loanCharge, loanPrincipal, chargeAmount, numberOfRepayments, loanChargeAmount);
loanCharge.setPaid(loanCharge.determineIfFullyPaid());
loanCharge.setExternalId(externalId);
return loanCharge;
}
public LoanCharge fetchLoanChargesById(final Loan loan, final Long id) {
@@ -402,4 +509,246 @@ public class LoanChargeService {
}
}
private void clearLoanInstallmentChargesBeforeRegeneration(final LoanCharge loanCharge) {
/*
* JW https://issues.apache.org/jira/browse/FINERACT-1557 For loan installment charges only : Clear down
* installment charges from the loanCharge and from each of the repayment installments and allow them to be
* recalculated fully anew. This patch is to avoid the 'merging' of existing and regenerated installment charges
* which results in the installment charges being deleted on loan approval if the schedule is regenerated. Not
* pretty. updateInstallmentCharges in LoanCharge.java: the merging looks like it will work but doesn't so this
* patch simply hits the part which 'adds all' rather than merge. Possibly an ORM issue. The issue could be to
* do with the fact that, on approval, the "recalculateLoanCharge" happens twice (probably 2 schedule
* regenerations) whereas it only happens once on Submit and Disburse (and no problems with them)
*
* if (this.loanInstallmentCharge.isEmpty()) { this.loanInstallmentCharge.addAll(newChargeInstallments);
*/
Loan loan = loanCharge.getLoan();
if (!loan.isSubmittedAndPendingApproval() && !loan.isApproved()) {
return;
} // doing for both just in case status is not
// updated at this points
if (loanCharge.isInstalmentFee()) {
loanCharge.clearLoanInstallmentCharges();
for (final LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) {
if (installment.isRecalculatedInterestComponent()) {
continue; // JW: does this in generateInstallmentLoanCharges - but don't understand it
}
installment.getInstallmentCharges().clear();
}
}
}
private void handleChargePaidTransaction(final Loan loan, final LoanCharge charge, final LoanTransaction chargesPayment,
final Integer installmentNumber) {
chargesPayment.updateLoan(loan);
final LoanChargePaidBy loanChargePaidBy = new LoanChargePaidBy(chargesPayment, charge,
chargesPayment.getAmount(loan.getCurrency()).getAmount(), installmentNumber);
chargesPayment.getLoanChargesPaid().add(loanChargePaidBy);
loan.addLoanTransaction(chargesPayment);
loanLifecycleStateMachine.transition(LoanEvent.LOAN_CHARGE_PAYMENT, loan);
final List<LoanRepaymentScheduleInstallment> chargePaymentInstallments = new ArrayList<>();
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
int firstNormalInstallmentNumber = LoanRepaymentScheduleProcessingWrapper
.fetchFirstNormalInstallmentNumber(loan.getRepaymentScheduleInstallments());
for (final LoanRepaymentScheduleInstallment installment : installments) {
boolean isFirstInstallment = installment.getInstallmentNumber().equals(firstNormalInstallmentNumber);
if (installment.getInstallmentNumber().equals(installmentNumber) || (installmentNumber == null
&& charge.isDueInPeriod(installment.getFromDate(), installment.getDueDate(), isFirstInstallment))) {
chargePaymentInstallments.add(installment);
break;
}
}
final Set<LoanCharge> loanCharges = new HashSet<>(1);
loanCharges.add(charge);
loanTransactionProcessingService.processLatestTransaction(loan.getTransactionProcessingStrategyCode(), chargesPayment,
new TransactionCtx(loan.getCurrency(), chargePaymentInstallments, loanCharges,
new MoneyHolder(loan.getTotalOverpaidAsMoney()), null));
loanLifecycleStateMachine.determineAndTransition(loan, chargesPayment.getTransactionDate());
}
private BigDecimal calculatePerInstallmentChargeAmount(final Loan loan, final LoanCharge loanCharge) {
return calculatePerInstallmentChargeAmount(loan, loanCharge.getChargeCalculation(), loanCharge.getPercentage());
}
private void updateInstallmentCharges(final LoanCharge loanCharge) {
final Collection<LoanInstallmentCharge> remove = new HashSet<>();
final List<LoanInstallmentCharge> newChargeInstallments = generateInstallmentLoanCharges(loanCharge.getLoan(), loanCharge);
if (loanCharge.getLoanInstallmentCharge().isEmpty()) {
loanCharge.getLoanInstallmentCharge().addAll(newChargeInstallments);
} else {
int index = 0;
final List<LoanInstallmentCharge> oldChargeInstallments = new ArrayList<>();
if (loanCharge.getLoanInstallmentCharge() != null && !loanCharge.getLoanInstallmentCharge().isEmpty()) {
oldChargeInstallments.addAll(loanCharge.getLoanInstallmentCharge());
}
Collections.sort(oldChargeInstallments);
final LoanInstallmentCharge[] loanChargePerInstallmentArray = newChargeInstallments
.toArray(new LoanInstallmentCharge[newChargeInstallments.size()]);
for (final LoanInstallmentCharge chargePerInstallment : oldChargeInstallments) {
if (index == loanChargePerInstallmentArray.length) {
remove.add(chargePerInstallment);
chargePerInstallment.getInstallment().getInstallmentCharges().remove(chargePerInstallment);
} else {
LoanInstallmentCharge newLoanInstallmentCharge = loanChargePerInstallmentArray[index++];
newLoanInstallmentCharge.getInstallment().getInstallmentCharges().remove(newLoanInstallmentCharge);
chargePerInstallment.copyFrom(newLoanInstallmentCharge);
}
}
loanCharge.getLoanInstallmentCharge().removeAll(remove);
while (index < loanChargePerInstallmentArray.length) {
loanCharge.getLoanInstallmentCharge().add(loanChargePerInstallmentArray[index++]);
}
}
Money amount = Money.zero(loanCharge.getLoan().getCurrency());
for (LoanInstallmentCharge charge : loanCharge.getLoanInstallmentCharge()) {
amount = amount.plus(charge.getAmount());
}
loanCharge.setAmount(amount.getAmount());
}
private List<LoanInstallmentCharge> generateInstallmentLoanCharges(final Loan loan, final LoanCharge loanCharge) {
final List<LoanInstallmentCharge> loanChargePerInstallments = new ArrayList<>();
if (loanCharge.isInstalmentFee()) {
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
for (final LoanRepaymentScheduleInstallment installment : installments) {
if (installment.isRecalculatedInterestComponent()) {
continue;
}
BigDecimal amount;
if (loanCharge.getChargeCalculation().isFlat()) {
amount = loanCharge.amountOrPercentage();
} else {
amount = calculateInstallmentChargeAmount(loan, loanCharge.getChargeCalculation(), loanCharge.getPercentage(),
installment).getAmount();
}
final LoanInstallmentCharge loanInstallmentCharge = new LoanInstallmentCharge(amount, loanCharge, installment);
installment.getInstallmentCharges().add(loanInstallmentCharge);
loanChargePerInstallments.add(loanInstallmentCharge);
}
}
return loanChargePerInstallments;
}
private BigDecimal calculateOverdueAmountPercentageAppliedTo(final Loan loan, final LoanCharge loanCharge,
final int penaltyWaitPeriod) {
LoanRepaymentScheduleInstallment installment = loanCharge.getOverdueInstallmentCharge().getInstallment();
LocalDate graceDate = DateUtils.getBusinessLocalDate().minusDays(penaltyWaitPeriod);
Money amount = Money.zero(loan.getCurrency());
if (DateUtils.isAfter(graceDate, installment.getDueDate())) {
amount = calculateOverdueAmountPercentageAppliedTo(loan, installment, loanCharge.getChargeCalculation());
if (!amount.isGreaterThanZero()) {
loanCharge.setActive(false);
}
} else {
loanCharge.setActive(false);
}
return amount.getAmount();
}
private Money calculateOverdueAmountPercentageAppliedTo(final Loan loan, final LoanRepaymentScheduleInstallment installment,
final ChargeCalculationType calculationType) {
return switch (calculationType) {
case PERCENT_OF_AMOUNT -> installment.getPrincipalOutstanding(loan.getCurrency());
case PERCENT_OF_AMOUNT_AND_INTEREST ->
installment.getPrincipalOutstanding(loan.getCurrency()).plus(installment.getInterestOutstanding(loan.getCurrency()));
case PERCENT_OF_INTEREST -> installment.getInterestOutstanding(loan.getCurrency());
default -> Money.zero(loan.getCurrency());
};
}
private void update(final LoanCharge loanCharge, final BigDecimal amount, final LocalDate dueDate, final BigDecimal loanPrincipal,
Integer numberOfRepayments, BigDecimal loanChargeAmount) {
if (dueDate != null) {
loanCharge.setDueDate(dueDate);
}
if (amount != null) {
switch (loanCharge.getChargeCalculation()) {
case INVALID:
break;
case FLAT:
if (loanCharge.isInstalmentFee()) {
if (numberOfRepayments == null) {
numberOfRepayments = loanCharge.getLoan().fetchNumberOfInstallmensAfterExceptions();
}
loanCharge.setAmount(amount.multiply(BigDecimal.valueOf(numberOfRepayments)));
} else {
loanCharge.setAmount(amount);
}
break;
case PERCENT_OF_AMOUNT:
case PERCENT_OF_AMOUNT_AND_INTEREST:
case PERCENT_OF_INTEREST:
case PERCENT_OF_DISBURSEMENT_AMOUNT:
loanCharge.setPercentage(amount);
loanCharge.setAmountPercentageAppliedTo(loanPrincipal);
if (loanChargeAmount.compareTo(BigDecimal.ZERO) == 0) {
loanChargeAmount = loanCharge.percentageOf(loanCharge.getAmountPercentageAppliedTo());
}
loanCharge.setAmount(loanCharge.minimumAndMaximumCap(loanChargeAmount));
break;
}
loanCharge.setAmountOrPercentage(amount);
loanCharge.setAmountOutstanding(loanCharge.calculateOutstanding());
if (loanCharge.getLoan() != null && loanCharge.isInstalmentFee()) {
updateInstallmentCharges(loanCharge);
}
}
}
private Money getTotalAllTrancheDisbursementAmount(final Loan loan) {
Money amount = Money.zero(loan.getCurrency());
if (loan.isMultiDisburmentLoan()) {
for (final LoanDisbursementDetails loanDisbursementDetail : loan.getDisbursementDetails()) {
amount = amount.plus(loanDisbursementDetail.principal());
}
}
return amount;
}
private List<Long> fetchAllLoanChargeIds(final Loan loan) {
List<Long> list = new ArrayList<>();
for (LoanCharge loanCharge : loan.getLoanCharges()) {
list.add(loanCharge.getId());
}
return list;
}
private Money calculateInstallmentChargeAmount(final Loan loan, final ChargeCalculationType calculationType,
final BigDecimal percentage, final LoanRepaymentScheduleInstallment installment) {
Money percentOf = switch (calculationType) {
case PERCENT_OF_AMOUNT -> installment.getPrincipal(loan.getCurrency());
case PERCENT_OF_AMOUNT_AND_INTEREST ->
installment.getPrincipal(loan.getCurrency()).plus(installment.getInterestCharged(loan.getCurrency()));
case PERCENT_OF_INTEREST -> installment.getInterestCharged(loan.getCurrency());
case PERCENT_OF_DISBURSEMENT_AMOUNT, INVALID, FLAT -> Money.zero(loan.getCurrency());
};
return Money.zero(loan.getCurrency()) //
.plus(LoanCharge.percentageOf(percentOf.getAmount(), percentage));
}
private BigDecimal getDerivedAmountForCharge(final Loan loan, final LoanCharge loanCharge) {
BigDecimal amount = BigDecimal.ZERO;
if (loan.isMultiDisburmentLoan() && loanCharge.getCharge().getChargeTimeType().equals(ChargeTimeType.DISBURSEMENT.getValue())) {
amount = loan.getApprovedPrincipal();
} else {
// If charge type is specified due date and loan is multi disburment loan.
// Then we need to get as of this loan charge due date how much amount disbursed.
if (loanCharge.isSpecifiedDueDate() && loan.isMultiDisburmentLoan()) {
for (final LoanDisbursementDetails loanDisbursementDetails : loan.getDisbursementDetails()) {
if (!DateUtils.isAfter(loanDisbursementDetails.expectedDisbursementDate(), loanCharge.getDueDate())) {
amount = amount.add(loanDisbursementDetails.principal());
}
}
} else {
amount = loan.getPrincipal().getAmount();
}
}
return amount;
}
}
@@ -62,6 +62,8 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
private final ReprocessLoanTransactionsService reprocessLoanTransactionsService;
private final LoanTransactionProcessingService loanTransactionProcessingService;
private final LoanLifecycleStateMachine loanLifecycleStateMachine;
private final LoanBalanceService loanBalanceService;
private final LoanTransactionService loanTransactionService;
@Override
public LoanTransaction handleDownPayment(ScheduleGeneratorDTO scheduleGeneratorDTO, JsonCommand command,
@@ -91,7 +93,8 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
loanTransaction.updateLoan(loan);
final boolean isTransactionChronologicallyLatest = loan.isChronologicallyLatestRepaymentOrWaiver(loanTransaction);
final boolean isTransactionChronologicallyLatest = loanTransactionService.isChronologicallyLatestRepaymentOrWaiver(loan,
loanTransaction);
if (loanTransaction.isNotZero()) {
loan.addLoanTransaction(loanTransaction);
@@ -128,6 +131,7 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
.fetchLoanRepaymentScheduleInstallmentByDueDate(loanTransaction.getTransactionDate());
boolean reprocessOnPostConditions = false;
boolean processLatest = isTransactionChronologicallyLatest //
&& adjustedTransaction == null // covers reversals
&& !loan.isForeclosure() //
@@ -163,7 +167,7 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
if (loanTransaction.isNotRecoveryRepayment()) {
loanLifecycleStateMachine.determineAndTransition(loan, loanTransaction.getTransactionDate());
} else {
loan.updateLoanSummaryDerivedFields();
loanBalanceService.updateLoanSummaryDerivedFields(loan);
}
if (loan.getLoanProduct().isMultiDisburseLoan()) {
@@ -0,0 +1,58 @@
/**
* 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 java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionComparator;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LoanTransactionService {
private final LoanTransactionRepository loanTransactionRepository;
public List<LoanTransaction> retrieveListOfTransactionsForReprocessing(final Loan loan) {
return loan.getLoanTransactions().stream().filter(loanTransactionForReprocessingPredicate())
.sorted(LoanTransactionComparator.INSTANCE).collect(Collectors.toList());
}
public boolean isChronologicallyLatestRepaymentOrWaiver(final Loan loan, final LoanTransaction loanTransaction) {
final Optional<LocalDate> lastTransactionDateForReprocessing = loanTransactionRepository
.findLastTransactionDateForReprocessing(loan);
return lastTransactionDateForReprocessing.isEmpty()
|| !DateUtils.isAfter(lastTransactionDateForReprocessing.get(), loanTransaction.getTransactionDate());
}
private Predicate<LoanTransaction> loanTransactionForReprocessingPredicate() {
return transaction -> transaction.isNotReversed() && (transaction.isChargeOff() || transaction.isReAge()
|| transaction.isAccrualActivity() || transaction.isReAmortize() || !transaction.isNonMonetaryTransaction());
}
}
@@ -25,12 +25,15 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class LoanScheduleComponent {
private final LoanBalanceService loanBalanceService;
public void updateLoanSchedule(Loan loan, final LoanScheduleModel modifiedLoanSchedule) {
final List<LoanScheduleModelPeriod> periods = modifiedLoanSchedule.getPeriods();
for (final LoanScheduleModelPeriod scheduledLoanInstallment : modifiedLoanSchedule.getPeriods()) {
@@ -55,7 +58,7 @@ public class LoanScheduleComponent {
loan.getRepaymentScheduleInstallments().removeIf(i -> !existInstallment(periods, i.getInstallmentNumber()));
loan.updateLoanScheduleDependentDerivedFields();
loan.updateLoanSummaryDerivedFields();
loanBalanceService.updateLoanSummaryDerivedFields(loan);
}
public void updateLoanSchedule(Loan loan, final List<LoanRepaymentScheduleInstallment> installments) {
@@ -72,7 +75,7 @@ public class LoanScheduleComponent {
loan.getRepaymentScheduleInstallments().removeIf(i -> !existInstallment(installments, i.getInstallmentNumber()));
loan.updateLoanScheduleDependentDerivedFields();
loan.updateLoanSummaryDerivedFields();
loanBalanceService.updateLoanSummaryDerivedFields(loan);
}
private LoanRepaymentScheduleInstallment findByInstallmentNumber(final Collection<LoanRepaymentScheduleInstallment> installments,
@@ -43,6 +43,10 @@ import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargeService;
import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionProcessingService;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
@@ -64,6 +68,9 @@ public class SingleLoanChargeRepaymentScheduleProcessingWrapperTest {
private ArgumentCaptor<Money> penaltyChargesWaived = ArgumentCaptor.forClass(Money.class);
private ArgumentCaptor<Money> penaltyChargesWrittenOff = ArgumentCaptor.forClass(Money.class);
private final LoanChargeService loanChargeService = new LoanChargeService(mock(LoanChargeValidator.class),
mock(LoanTransactionProcessingService.class), mock(LoanLifecycleStateMachine.class), mock(LoanBalanceService.class));
@BeforeAll
public static void init() {
MONEY_HELPER.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.HALF_EVEN);
@@ -141,7 +148,8 @@ public class SingleLoanChargeRepaymentScheduleProcessingWrapperTest {
when(charge.isPenalty()).thenReturn(penalty);
Loan loan = mock(Loan.class);
when(loan.isInterestBearing()).thenReturn(false);
return new LoanCharge(loan, charge, new BigDecimal(1000), new BigDecimal(10), ChargeTimeType.SPECIFIED_DUE_DATE,
return loanChargeService.create(loan, charge, new BigDecimal(1000), new BigDecimal(10), ChargeTimeType.SPECIFIED_DUE_DATE,
ChargeCalculationType.FLAT, LocalDate.of(2023, 1, 15), ChargePaymentMode.REGULAR, 1, null, null);
}