FINERACT-2080: Move loan validation from entity to validator classes

This commit is contained in:
Oleksii Novikov
2024-12-05 13:58:47 +01:00
committed by Adam Saghy
parent 56ee2eaaff
commit a2c01d25e5
40 changed files with 2867 additions and 2146 deletions
File diff suppressed because it is too large Load Diff
@@ -448,7 +448,6 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
}
public void reverse() {
this.loan.validateRepaymentTypeTransactionNotBeforeAChargeRefund(this, "reversed");
this.reversed = true;
this.reversedOnDate = DateUtils.getBusinessLocalDate();
this.loanTransactionToRepaymentScheduleMappings.clear();
@@ -50,6 +50,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.SingleLoanChargeRepaymen
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor;
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.springframework.util.CollectionUtils;
/**
@@ -64,6 +65,7 @@ import org.springframework.util.CollectionUtils;
public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implements LoanRepaymentScheduleTransactionProcessor {
public final SingleLoanChargeRepaymentScheduleProcessingWrapper loanChargeProcessor = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
public final LoanChargeValidator loanChargeValidator = new LoanChargeValidator();
@Override
public boolean accept(String s) {
@@ -501,6 +503,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
protected void createNewTransaction(LoanTransaction loanTransaction, LoanTransaction newLoanTransaction,
ChangedTransactionDetail changedTransactionDetail) {
loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(loanTransaction.getLoan(), loanTransaction, "reversed");
loanTransaction.reverse();
loanTransaction.updateExternalId(null);
newLoanTransaction.copyLoanTransactionRelations(loanTransaction.getLoanTransactionRelations());
@@ -0,0 +1,101 @@
/**
* 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.serialization;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.charge.exception.LoanChargeCannotBeAddedException;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTransitionException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanChargeRefundException;
import org.springframework.stereotype.Component;
@Component
public final class LoanChargeValidator {
public void validateLoanIsNotClosed(final Loan loan, final LoanCharge loanCharge) {
if (loan.isClosed()) {
final String defaultUserMessage = "This charge cannot be added as the loan is already closed.";
throw new LoanChargeCannotBeAddedException("loanCharge", "loan.is.closed", defaultUserMessage, loan.getId(), loanCharge.name());
}
}
// NOTE: to remove this constraint requires that loan transactions
// that represent the waive of charges also be removed (or reversed)M
// if you want ability to remove loan charges that are waived.
public void validateLoanChargeIsNotWaived(final Loan loan, final LoanCharge loanCharge) {
if (loanCharge.isWaived()) {
final String defaultUserMessage = "This loan charge cannot be removed as the charge as already been waived.";
throw new LoanChargeCannotBeAddedException("loanCharge", "loanCharge.is.waived", defaultUserMessage, loan.getId(),
loanCharge.name());
}
}
public void validateChargeHasValidSpecifiedDateIfApplicable(final Loan loan, final LoanCharge loanCharge,
final LocalDate disbursementDate) {
if (loanCharge.isSpecifiedDueDate() && DateUtils.isBefore(loanCharge.getDueLocalDate(), disbursementDate)) {
final String defaultUserMessage = "This charge with specified due date cannot be added as the it is not in schedule range.";
throw new LoanChargeCannotBeAddedException("loanCharge", "specified.due.date.outside.range", defaultUserMessage,
loan.getDisbursementDate(), loanCharge.name());
}
}
public void validateChargeAdditionForDisbursedLoan(final Loan loan, final LoanCharge loanCharge) {
if (loan.isChargesAdditionAllowed() && loanCharge.isDueAtDisbursement()) {
// Note: added this constraint to restrict adding disbursement
// charges to a loan
// after it is disbursed
// if the loan charge payment type is 'Disbursement'.
// To undo this constraint would mean resolving how charges due are
// disbursement are handled at present.
// When a loan is disbursed and has charges due at disbursement, a
// transaction is created to auto record
// payment of the charges (user has no choice in saying they were or
// were not paid) - so its assumed they were paid.
final String defaultUserMessage = "This charge which is due at disbursement cannot be added as the loan is already disbursed.";
throw new LoanChargeCannotBeAddedException("loanCharge", "due.at.disbursement.and.loan.is.disbursed", defaultUserMessage,
loan.getId(), loanCharge.name());
}
}
public void validateRepaymentTypeTransactionNotBeforeAChargeRefund(final Loan loan, final LoanTransaction repaymentTransaction,
final String reversedOrCreated) {
if (repaymentTransaction.isRepaymentLikeType() && !repaymentTransaction.isChargeRefund()) {
for (LoanTransaction txn : loan.getLoanTransactions()) {
if (txn.isChargeRefund() && DateUtils.isBefore(repaymentTransaction.getTransactionDate(), txn.getTransactionDate())) {
final String errorMessage = "loan.transaction.cant.be." + reversedOrCreated + ".because.later.charge.refund.exists";
final String details = "Loan Transaction: " + loan.getId() + " Can't be " + reversedOrCreated
+ " because a Later Charge Refund Exists.";
throw new LoanChargeRefundException(errorMessage, details);
}
}
}
}
public void validateChargePaymentNotInFuture(final LoanTransaction paymentTransaction) {
if (DateUtils.isDateInTheFuture(paymentTransaction.getTransactionDate())) {
final String errorMessage = "The date on which a loan charge paid cannot be in the future.";
throw new InvalidLoanStateTransitionException("charge.payment", "cannot.be.a.future.date", errorMessage,
paymentTransaction.getTransactionDate());
}
}
}
@@ -0,0 +1,224 @@
/**
* 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.serialization;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.organisation.holiday.domain.Holiday;
import org.apache.fineract.organisation.holiday.service.HolidayUtil;
import org.apache.fineract.organisation.workingdays.domain.WorkingDays;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysUtil;
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.springframework.stereotype.Component;
@Component
public final class LoanDownPaymentTransactionValidator {
public void validateRepaymentDateIsOnNonWorkingDay(final LocalDate repaymentDate, final WorkingDays workingDays,
final boolean allowTransactionsOnNonWorkingDay) {
if (!allowTransactionsOnNonWorkingDay && !WorkingDaysUtil.isWorkingDay(workingDays, repaymentDate)) {
final String errorMessage = "Repayment date cannot be on a non working day";
throw new LoanApplicationDateException("repayment.date.on.non.working.day", errorMessage, repaymentDate);
}
}
public void validateRepaymentDateIsOnHoliday(final LocalDate repaymentDate, final boolean allowTransactionsOnHoliday,
final List<Holiday> holidays) {
if (!allowTransactionsOnHoliday && HolidayUtil.isHoliday(repaymentDate, holidays)) {
final String errorMessage = "Repayment date cannot be on a holiday";
throw new LoanApplicationDateException("repayment.date.on.holiday", errorMessage, repaymentDate);
}
}
public void validateLoanStatusIsActiveOrFullyPaidOrOverpaid(final Loan loan) {
if (!(loan.isOpen() || loan.isClosedObligationsMet() || loan.isOverPaid())) {
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",
defaultUserMessage);
dataValidationErrors.add(error);
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
public void validateRepaymentTypeAccountStatus(final Loan loan, final LoanTransaction repaymentTransaction, final LoanEvent event) {
if (repaymentTransaction.isGoodwillCredit() || repaymentTransaction.isInterestPaymentWaiver()
|| repaymentTransaction.isMerchantIssuedRefund() || repaymentTransaction.isPayoutRefund()
|| repaymentTransaction.isChargeRefund() || repaymentTransaction.isRepayment() || repaymentTransaction.isDownPayment()
|| repaymentTransaction.isInterestRefund()) {
validateLoanStatusIsActiveOrFullyPaidOrOverpaid(loan);
} else {
validateAccountStatus(loan, event);
}
}
public void validateAccountStatus(final Loan loan, final LoanEvent event) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
switch (event) {
case LOAN_APPROVED -> {
if (!loan.isSubmittedAndPendingApproval()) {
final String defaultUserMessage = "Loan Account Approval is not allowed. Loan Account is not in submitted and pending approval state.";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.approve.account.is.not.submitted.and.pending.state", defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_APPROVAL_UNDO -> {
if (!loan.isApproved()) {
final String defaultUserMessage = "Loan Account Undo Approval is not allowed. Loan Account is not in approved state.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.undo.approval.account.is.not.approved",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_DISBURSED -> {
if ((!(loan.isApproved() && loan.isNotDisbursed()) && !loan.getLoanProduct().isMultiDisburseLoan())
|| (loan.getLoanProduct().isMultiDisburseLoan() && !loan.isAllTranchesNotDisbursed())) {
final String defaultUserMessage = "Loan Disbursal is not allowed. Loan Account is not in approved and not disbursed state.";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.disbursal.account.is.not.approve.not.disbursed.state", defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_DISBURSAL_UNDO -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Loan Undo disbursal is not allowed. Loan Account is not active.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.undo.disbursal.account.is.not.active",
defaultUserMessage);
dataValidationErrors.add(error);
}
if (loan.isOpen() && loan.isTopup()) {
final String defaultUserMessage = "Loan Undo disbursal is not allowed on Topup Loans";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.undo.disbursal.not.allowed.on.topup.loan", defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_REPAYMENT_OR_WAIVER -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Loan Repayment (or its types) or Waiver is not allowed. Loan Account is not active.";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.repayment.or.waiver.account.is.not.active", defaultUserMessage);
dataValidationErrors.add(error);
}
}
case WRITE_OFF_OUTSTANDING -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Loan Written off is not allowed. Loan Account is not active.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.writtenoff.account.is.not.active",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case WRITE_OFF_OUTSTANDING_UNDO -> {
if (!loan.isClosedWrittenOff()) {
final String defaultUserMessage = "Loan Undo Written off is not allowed. Loan Account is not Written off.";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.undo.writtenoff.account.is.not.written.off", defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_CHARGE_PAYMENT -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Charge payment is not allowed. Loan Account is not Active.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.charge.payment.account.is.not.active",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_CLOSED -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Closing Loan Account is not allowed. Loan Account is not Active.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.close.account.is.not.active",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_EDIT_MULTI_DISBURSE_DATE -> {
if (loan.isClosed()) {
final String defaultUserMessage = "Edit disbursement is not allowed. Loan Account is not active.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.edit.disbursement.account.is.not.active",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_RECOVERY_PAYMENT -> {
if (!loan.isClosedWrittenOff()) {
final String defaultUserMessage = "Recovery repayments may only be made on loans which are written off";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.account.is.not.written.off",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_REFUND -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Loan Refund is not allowed. Loan Account is not active.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.refund.account.is.not.active",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_DISBURSAL_UNDO_LAST -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Loan Undo last disbursal is not allowed. Loan Account is not active.";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.undo.last.disbursal.account.is.not.active", defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_FORECLOSURE -> {
if (!loan.isOpen()) {
final String defaultUserMessage = "Loan foreclosure is not allowed. Loan Account is not active.";
final ApiParameterError error = ApiParameterError.generalError("error.msg.loan.foreclosure.account.is.not.active",
defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_CREDIT_BALANCE_REFUND -> {
if (!loan.getStatus().isOverpaid()) {
final String defaultUserMessage = "Loan Credit Balance Refund is not allowed. Loan Account is not Overpaid.";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.credit.balance.refund.account.is.not.overpaid", defaultUserMessage);
dataValidationErrors.add(error);
}
}
case LOAN_CHARGE_ADJUSTMENT -> {
if (!(loan.getStatus().isActive() || loan.getStatus().isClosedObligationsMet() || loan.getStatus().isOverpaid())) {
final String defaultUserMessage = "Loan Charge Adjustment is not allowed. Loan Account must be either Active, Fully repaid or Overpaid.";
final ApiParameterError error = ApiParameterError
.generalError("error.msg.loan.charge.adjustment.account.is.not.in.valid.state", defaultUserMessage);
dataValidationErrors.add(error);
}
}
default -> {
}
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
}
@@ -0,0 +1,114 @@
/**
* 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.serialization;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
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.exception.InvalidLoanStateTransitionException;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException;
import org.springframework.stereotype.Component;
@Component
public final class LoanRefundValidator {
public void validateTransferRefund(final Loan loan, final LoanTransaction loanTransaction) {
if (loan.getStatus().isOverpaid()) {
if (loan.getTotalOverpaid().compareTo(loanTransaction.getAmount(loan.getCurrency()).getAmount()) < 0) {
final String errorMessage = "The refund amount must be less than or equal to overpaid amount ";
throw new InvalidLoanStateTransitionException("transaction", "is.exceeding.overpaid.amount", errorMessage,
loan.getTotalOverpaid(), loanTransaction.getAmount(loan.getCurrency()).getAmount());
} else if (!loan.isAfterLastRepayment(loanTransaction, loan.getLoanTransactions())) {
final String errorMessage = "Transfer funds is allowed only after last repayment date";
throw new InvalidLoanStateTransitionException("transaction", "is.not.after.repayment.date", errorMessage);
}
} else {
final String errorMessage = "Transfer funds is allowed only for loan accounts with overpaid status ";
throw new InvalidLoanStateTransitionException("transaction", "is.not.a.overpaid.loan", errorMessage);
}
}
public void validateTransactionDateAfterDisbursement(final Loan loan, final LocalDate loanTransactionDate) {
if (DateUtils.isBefore(loanTransactionDate, loan.getDisbursementDate())) {
final String errorMessage = "The transaction date cannot be before the loan disbursement date: "
+ loan.getDisbursementDate().toString();
throw new InvalidLoanStateTransitionException("transaction", "cannot.be.before.disbursement.date", errorMessage,
loanTransactionDate, loan.getDisbursementDate());
}
}
public void validateCreditBalanceRefund(final Loan loan, final LoanTransaction newCreditBalanceRefundTransaction) {
if (!newCreditBalanceRefundTransaction.isGreaterThanZeroAndLessThanOrEqualTo(loan.getTotalOverpaid())) {
final String errorMessage = "Transaction Amount ("
+ newCreditBalanceRefundTransaction.getAmount(loan.getCurrency()).getAmount().toString()
+ ") must be > zero and <= Overpaid amount (" + loan.getTotalOverpaid().toString() + ").";
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final ApiParameterError error = ApiParameterError.parameterError(
"error.msg.transactionAmount.invalid.must.be.>zero.and<=overpaidamount", errorMessage, "transactionAmount",
newCreditBalanceRefundTransaction.getAmount(loan.getCurrency()));
dataValidationErrors.add(error);
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
public void validateRefundEligibility(final Loan loan, final LoanTransaction loanTransaction) {
if (loan.getStatus().isOverpaid() || loan.getStatus().isClosed()) {
final String errorMessage = "This refund option is only for active loans ";
throw new InvalidLoanStateTransitionException("transaction", "is.exceeding.overpaid.amount", errorMessage,
loan.getTotalOverpaid(), loanTransaction.getAmount(loan.getCurrency()).getAmount());
} else if (loan.getTotalPaidInRepayments().isZero()) {
final String errorMessage = "Cannot refund when no payment has been made";
throw new InvalidLoanStateTransitionException("transaction", "no.payment.yet.made.for.loan", errorMessage);
}
}
public void validateRefundTransactionType(final LoanTransaction loanTransaction) {
if (loanTransaction.isNotRefundForActiveLoan()) {
final String errorMessage = "A transaction of type refund was expected but not received.";
throw new InvalidLoanTransactionTypeException("transaction", "is.not.a.refund.transaction", errorMessage);
}
}
public void validateTransactionDateNotInFuture(final LocalDate transactionDate) {
if (DateUtils.isDateInTheFuture(transactionDate)) {
final String errorMessage = "The transaction date cannot be in the future.";
throw new InvalidLoanStateTransitionException("transaction", "cannot.be.a.future.date", errorMessage, transactionDate);
}
}
public void validateTransactionAmountThreshold(final Loan loan, final LoanTransaction adjustedTransaction) {
if (loan.getLoanProduct().isMultiDisburseLoan() && adjustedTransaction == null) {
final BigDecimal totalDisbursed = loan.getDisbursedAmount();
final BigDecimal totalPrincipalAdjusted = loan.getSummary().getTotalPrincipalAdjustments();
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted);
if (totalPrincipalCredited.compareTo(loan.getSummary().getTotalPrincipalRepaid()) < 0) {
final String errorMessage = "The transaction amount cannot exceed threshold.";
throw new InvalidLoanStateTransitionException("transaction", "amount.exceeds.threshold", errorMessage);
}
}
}
}
@@ -0,0 +1,84 @@
/**
* 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.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
@RequiredArgsConstructor
public class LoanChargeService {
private final LoanChargeValidator loanChargeValidator;
public void recalculateAllCharges(final Loan loan) {
Set<LoanCharge> charges = loan.getActiveCharges();
int penaltyWaitPeriod = 0;
for (final LoanCharge loanCharge : charges) {
recalculateLoanCharge(loan, loanCharge, penaltyWaitPeriod);
}
loan.updateSummaryWithTotalFeeChargesDueAtDisbursement(loan.deriveSumTotalOfChargesDueAtDisbursement());
}
public void recalculateLoanCharge(final Loan loan, final LoanCharge loanCharge, final int penaltyWaitPeriod) {
BigDecimal amount = BigDecimal.ZERO;
BigDecimal chargeAmt;
BigDecimal totalChargeAmt = BigDecimal.ZERO;
if (loanCharge.getChargeCalculation().isPercentageBased()) {
if (loanCharge.isOverdueInstallmentCharge()) {
amount = loan.calculateOverdueAmountPercentageAppliedTo(loanCharge, penaltyWaitPeriod);
} else {
amount = loan.calculateAmountPercentageAppliedTo(loanCharge);
}
chargeAmt = loanCharge.getPercentage();
if (loanCharge.isInstalmentFee()) {
totalChargeAmt = loan.calculatePerInstallmentChargeAmount(loanCharge);
}
} else {
chargeAmt = loanCharge.amountOrPercentage();
}
if (loanCharge.isActive()) {
loan.clearLoanInstallmentChargesBeforeRegeneration(loanCharge);
loanCharge.update(chargeAmt, loanCharge.getDueLocalDate(), amount, loan.fetchNumberOfInstallmensAfterExceptions(),
totalChargeAmt);
loanChargeValidator.validateChargeHasValidSpecifiedDateIfApplicable(loan, loanCharge, loan.getDisbursementDate());
}
}
public void makeChargePayment(final Loan loan, final Long chargeId, final LoanLifecycleStateMachine loanLifecycleStateMachine,
final List<Long> existingTransactionIds, final List<Long> existingReversedTransactionIds,
final LoanTransaction paymentTransaction, final Integer installmentNumber) {
loanChargeValidator.validateChargePaymentNotInFuture(paymentTransaction);
existingTransactionIds.addAll(loan.findExistingTransactionIds());
existingReversedTransactionIds.addAll(loan.findExistingReversedTransactionIds());
LoanCharge charge = null;
for (final LoanCharge loanCharge : loan.getCharges()) {
if (loanCharge.isActive() && chargeId.equals(loanCharge.getId())) {
charge = loanCharge;
}
}
loan.handleChargePaidTransaction(charge, paymentTransaction, loanLifecycleStateMachine, installmentNumber);
}
}
@@ -20,11 +20,17 @@ package org.apache.fineract.portfolio.loanaccount.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public interface LoanDownPaymentHandlerService {
LoanTransaction handleDownPayment(ScheduleGeneratorDTO scheduleGeneratorDTO, JsonCommand command,
LoanTransaction disbursementTransaction, Loan loan);
ChangedTransactionDetail handleRepaymentOrRecoveryOrWaiverTransaction(Loan loan, LoanTransaction newTransactionDetail,
LoanLifecycleStateMachine loanLifecycleStateMachine, LoanTransaction transactionForAdjustment,
ScheduleGeneratorDTO scheduleGeneratorDTO);
}
@@ -18,17 +18,39 @@
*/
package org.apache.fineract.portfolio.loanaccount.service;
import static org.apache.fineract.portfolio.loanaccount.domain.Loan.ACTUAL_DISBURSEMENT_DATE;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.infrastructure.core.service.MathUtil;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanBalanceChangedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionDownPaymentPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionDownPaymentPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent;
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTransitionException;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanDownPaymentTransactionValidator;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanRefundValidator;
@Slf4j
@RequiredArgsConstructor
@@ -36,12 +58,16 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
private final LoanTransactionRepository loanTransactionRepository;
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator;
private final LoanScheduleService loanScheduleService;
private final LoanRefundService loanRefundService;
private final LoanRefundValidator loanRefundValidator;
@Override
public LoanTransaction handleDownPayment(ScheduleGeneratorDTO scheduleGeneratorDTO, JsonCommand command,
LoanTransaction disbursementTransaction, Loan loan) {
businessEventNotifierService.notifyPreBusinessEvent(new LoanTransactionDownPaymentPreBusinessEvent(loan));
LoanTransaction downPaymentTransaction = loan.handleDownPayment(disbursementTransaction, command, scheduleGeneratorDTO);
LoanTransaction downPaymentTransaction = handleDownPayment(loan, disbursementTransaction, command, scheduleGeneratorDTO);
if (downPaymentTransaction != null) {
downPaymentTransaction = loanTransactionRepository.saveAndFlush(downPaymentTransaction);
businessEventNotifierService.notifyPostBusinessEvent(new LoanTransactionDownPaymentPostBusinessEvent(downPaymentTransaction));
@@ -49,4 +75,165 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
}
return downPaymentTransaction;
}
@Override
public ChangedTransactionDetail handleRepaymentOrRecoveryOrWaiverTransaction(final Loan loan, final LoanTransaction loanTransaction,
final LoanLifecycleStateMachine loanLifecycleStateMachine, final LoanTransaction adjustedTransaction,
final ScheduleGeneratorDTO scheduleGeneratorDTO) {
ChangedTransactionDetail changedTransactionDetail = null;
if (loanTransaction.isRecoveryRepayment()) {
loanLifecycleStateMachine.transition(LoanEvent.LOAN_RECOVERY_PAYMENT, loan);
}
if (loanTransaction.isRecoveryRepayment()
&& loanTransaction.getAmount(loan.getCurrency()).getAmount().compareTo(loan.getSummary().getTotalWrittenOff()) > 0) {
final String errorMessage = "The transaction amount cannot greater than the remaining written off amount.";
throw new InvalidLoanStateTransitionException("transaction", "cannot.be.greater.than.total.written.off", errorMessage);
}
loanTransaction.updateLoan(loan);
final boolean isTransactionChronologicallyLatest = loan.isChronologicallyLatestRepaymentOrWaiver(loanTransaction);
if (loanTransaction.isNotZero()) {
loan.addLoanTransaction(loanTransaction);
}
if (loanTransaction.isNotRepaymentLikeType() && loanTransaction.isNotWaiver() && loanTransaction.isNotRecoveryRepayment()) {
final String errorMessage = "A transaction of type repayment or recovery repayment or waiver was expected but not received.";
throw new InvalidLoanTransactionTypeException("transaction", "is.not.a.repayment.or.waiver.or.recovery.transaction",
errorMessage);
}
final LocalDate loanTransactionDate = loanRefundService.extractTransactionDate(loan, loanTransaction);
if (DateUtils.isDateInTheFuture(loanTransactionDate)) {
final String errorMessage = "The transaction date cannot be in the future.";
throw new InvalidLoanStateTransitionException("transaction", "cannot.be.a.future.date", errorMessage, loanTransactionDate);
}
if (loanTransaction.isInterestWaiver()) {
Money totalInterestOutstandingOnLoan = loan.getTotalInterestOutstandingOnLoan();
if (adjustedTransaction != null) {
totalInterestOutstandingOnLoan = totalInterestOutstandingOnLoan.plus(adjustedTransaction.getAmount(loan.loanCurrency()));
}
if (loanTransaction.getAmount(loan.getCurrency()).isGreaterThan(totalInterestOutstandingOnLoan)) {
final String errorMessage = "The amount of interest to waive cannot be greater than total interest outstanding on loan.";
throw new InvalidLoanStateTransitionException("waive.interest", "amount.exceeds.total.outstanding.interest", errorMessage,
loanTransaction.getAmount(loan.getCurrency()), totalInterestOutstandingOnLoan.getAmount());
}
}
loanRefundValidator.validateTransactionAmountThreshold(loan, adjustedTransaction);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loan.getTransactionProcessor();
final LoanRepaymentScheduleInstallment currentInstallment = loan
.fetchLoanRepaymentScheduleInstallmentByDueDate(loanTransaction.getTransactionDate());
boolean reprocess = loan.isForeclosure() || !isTransactionChronologicallyLatest || adjustedTransaction != null
|| !DateUtils.isEqualBusinessDate(loanTransaction.getTransactionDate()) || currentInstallment == null
|| !currentInstallment.getTotalOutstanding(loan.getCurrency()).isEqualTo(loanTransaction.getAmount(loan.getCurrency()));
if (isTransactionChronologicallyLatest && adjustedTransaction == null
&& (!reprocess || !loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) && !loan.isForeclosure()) {
loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction,
new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(),
new MoneyHolder(loan.getTotalOverpaidAsMoney()), null));
reprocess = false;
if (loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) {
if (currentInstallment == null || currentInstallment.isNotFullyPaidOff()) {
reprocess = true;
} else {
final LoanRepaymentScheduleInstallment nextInstallment = loan
.fetchRepaymentScheduleInstallment(currentInstallment.getInstallmentNumber() + 1);
if (nextInstallment != null && nextInstallment.getTotalPaidInAdvance(loan.getCurrency()).isGreaterThanZero()) {
reprocess = true;
}
}
}
}
if (reprocess) {
if (loan.repaymentScheduleDetail().isInterestRecalculationEnabled()
&& !loan.getLoanProductRelatedDetail().getLoanScheduleType().equals(LoanScheduleType.PROGRESSIVE)) {
loanScheduleService.regenerateRepaymentScheduleWithInterestRecalculation(loan, scheduleGeneratorDTO);
}
changedTransactionDetail = loan.reprocessTransactions();
}
loan.updateLoanSummaryDerivedFields();
/**
* FIXME: Vishwas, skipping post loan transaction checks for Loan recoveries
**/
if (loanTransaction.isNotRecoveryRepayment()) {
loan.doPostLoanTransactionChecks(loanTransaction.getTransactionDate(), loanLifecycleStateMachine);
}
if (loan.getLoanProduct().isMultiDisburseLoan()) {
final BigDecimal totalDisbursed = loan.getDisbursedAmount();
final BigDecimal totalPrincipalAdjusted = loan.getSummary().getTotalPrincipalAdjustments();
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted);
if (totalPrincipalCredited.compareTo(loan.getSummary().getTotalPrincipalRepaid()) < 0
&& loan.repaymentScheduleDetail().getPrincipal().minus(totalDisbursed).isGreaterThanZero()) {
final String errorMessage = "The transaction amount cannot exceed threshold.";
throw new InvalidLoanStateTransitionException("transaction", "amount.exceeds.threshold", errorMessage);
}
}
return changedTransactionDetail;
}
private LoanTransaction handleDownPayment(final Loan loan, final LoanTransaction disbursementTransaction, final JsonCommand command,
final ScheduleGeneratorDTO scheduleGeneratorDTO) {
final LocalDate disbursedOn = command.localDateValueOfParameterNamed(ACTUAL_DISBURSEMENT_DATE);
final BigDecimal disbursedAmountPercentageForDownPayment = loan.getLoanRepaymentScheduleDetail()
.getDisbursedAmountPercentageForDownPayment();
ExternalId externalId = ExternalId.empty();
if (TemporaryConfigurationServiceContainer.isExternalIdAutoGenerationEnabled()) {
externalId = ExternalId.generate();
}
Money downPaymentMoney = Money.of(loan.getCurrency(),
MathUtil.percentageOf(disbursementTransaction.getAmount(), disbursedAmountPercentageForDownPayment, 19));
if (loan.getLoanProduct().getInstallmentAmountInMultiplesOf() != null) {
downPaymentMoney = Money.roundToMultiplesOf(downPaymentMoney, loan.getLoanProduct().getInstallmentAmountInMultiplesOf());
}
final Money adjustedDownPaymentMoney = switch (loan.getLoanProductRelatedDetail().getLoanScheduleType()) {
// For Cumulative loan: To check whether the loan was overpaid when the disbursement happened and to get the
// proper amount after the disbursement we are using two balances:
// 1. Whether the loan is still overpaid after the disbursement,
// 2. if the loan is not overpaid anymore after the disbursement, but was it more overpaid than the
// calculated down-payment amount?
case CUMULATIVE -> {
if (loan.getTotalOverpaidAsMoney().isGreaterThanZero()) {
yield Money.zero(loan.getCurrency());
}
yield MathUtil.negativeToZero(downPaymentMoney.minus(MathUtil.negativeToZero(disbursementTransaction
.getAmount(loan.getCurrency()).minus(disbursementTransaction.getOutstandingLoanBalanceMoney(loan.getCurrency())))));
}
// For Progressive loan: Disbursement transaction portion balances are enough to see whether the overpayment
// amount was more than the calculated down-payment amount
case PROGRESSIVE ->
MathUtil.negativeToZero(downPaymentMoney.minus(disbursementTransaction.getOverPaymentPortion(loan.getCurrency())));
};
if (adjustedDownPaymentMoney.isGreaterThanZero()) {
final LoanTransaction downPaymentTransaction = LoanTransaction.downPayment(loan.getOffice(), adjustedDownPaymentMoney, null,
disbursedOn, externalId);
final LoanEvent event = LoanEvent.LOAN_REPAYMENT_OR_WAIVER;
loanDownPaymentTransactionValidator.validateRepaymentTypeAccountStatus(loan, downPaymentTransaction, event);
final HolidayDetailDTO holidayDetailDTO = scheduleGeneratorDTO.getHolidayDetailDTO();
loanDownPaymentTransactionValidator.validateRepaymentDateIsOnHoliday(downPaymentTransaction.getTransactionDate(),
holidayDetailDTO.isAllowTransactionsOnHoliday(), holidayDetailDTO.getHolidays());
loanDownPaymentTransactionValidator.validateRepaymentDateIsOnNonWorkingDay(downPaymentTransaction.getTransactionDate(),
holidayDetailDTO.getWorkingDays(), holidayDetailDTO.isAllowTransactionsOnNonWorkingDay());
handleRepaymentOrRecoveryOrWaiverTransaction(loan, downPaymentTransaction, loan.getLoanLifecycleStateMachine(), null,
scheduleGeneratorDTO);
return downPaymentTransaction;
} else {
return null;
}
}
}
@@ -0,0 +1,123 @@
/**
* 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 lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent;
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanRefundValidator;
@RequiredArgsConstructor
public class LoanRefundService {
private final LoanRefundValidator loanRefundValidator;
public void makeRefund(final Loan loan, final LoanTransaction loanTransaction,
final LoanLifecycleStateMachine loanLifecycleStateMachine, final List<Long> existingTransactionIds,
final List<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(loan.findExistingTransactionIds());
existingReversedTransactionIds.addAll(loan.findExistingReversedTransactionIds());
loanRefundValidator.validateTransferRefund(loan, loanTransaction);
loanTransaction.updateLoan(loan);
if (loanTransaction.isNotZero()) {
loan.addLoanTransaction(loanTransaction);
}
loan.updateLoanSummaryDerivedFields();
loan.doPostLoanTransactionChecks(loanTransaction.getTransactionDate(), loanLifecycleStateMachine);
}
public LocalDate extractTransactionDate(final Loan loan, final LoanTransaction loanTransaction) {
final LocalDate loanTransactionDate = loanTransaction.getTransactionDate();
loanRefundValidator.validateTransactionDateAfterDisbursement(loan, loanTransactionDate);
return loanTransactionDate;
}
public void makeRefundForActiveLoan(final Loan loan, final LoanTransaction loanTransaction,
final LoanLifecycleStateMachine loanLifecycleStateMachine, final List<Long> existingTransactionIds,
final List<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(loan.findExistingTransactionIds());
existingReversedTransactionIds.addAll(loan.findExistingReversedTransactionIds());
handleRefundTransaction(loan, loanTransaction, loanLifecycleStateMachine, null);
}
public void creditBalanceRefund(final Loan loan, final LoanTransaction newCreditBalanceRefundTransaction,
final LoanLifecycleStateMachine defaultLoanLifecycleStateMachine, final List<Long> existingTransactionIds,
final List<Long> existingReversedTransactionIds) {
loanRefundValidator.validateCreditBalanceRefund(loan, newCreditBalanceRefundTransaction);
existingTransactionIds.addAll(loan.findExistingTransactionIds());
existingReversedTransactionIds.addAll(loan.findExistingReversedTransactionIds());
loan.getLoanTransactions().add(newCreditBalanceRefundTransaction);
loan.updateLoanSummaryDerivedFields();
if (MathUtil.isEmpty(loan.getTotalOverpaid())) {
loan.setOverpaidOnDate(null);
loan.setClosedOnDate(newCreditBalanceRefundTransaction.getTransactionDate());
defaultLoanLifecycleStateMachine.transition(LoanEvent.LOAN_CREDIT_BALANCE_REFUND, loan);
}
}
private void handleRefundTransaction(final Loan loan, final LoanTransaction loanTransaction,
final LoanLifecycleStateMachine loanLifecycleStateMachine, final LoanTransaction adjustedTransaction) {
loanLifecycleStateMachine.transition(LoanEvent.LOAN_REFUND, loan);
loanTransaction.updateLoan(loan);
loanRefundValidator.validateRefundEligibility(loan, loanTransaction);
if (loanTransaction.isNotZero()) {
loan.addLoanTransaction(loanTransaction);
}
loanRefundValidator.validateRefundTransactionType(loanTransaction);
final LocalDate loanTransactionDate = extractTransactionDate(loan, loanTransaction);
loanRefundValidator.validateTransactionDateNotInFuture(loanTransactionDate);
loanRefundValidator.validateTransactionAmountThreshold(loan, adjustedTransaction);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loan.getTransactionProcessor();
// If it's a refund
if (adjustedTransaction == null) {
loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction,
new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(),
new MoneyHolder(loan.getTotalOverpaidAsMoney()), null));
} else {
loan.reprocessTransactions();
}
loan.updateLoanSummaryDerivedFields();
loan.doPostLoanTransactionChecks(loanTransaction.getTransactionDate(), loanLifecycleStateMachine);
}
}
@@ -0,0 +1,121 @@
/**
* 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.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel;
@RequiredArgsConstructor
public class LoanScheduleService {
private final LoanChargeService loanChargeService;
/**
* Ability to regenerate the repayment schedule based on the loans current details/state.
*/
public void regenerateRepaymentSchedule(final Loan loan, final ScheduleGeneratorDTO scheduleGeneratorDTO) {
final LoanScheduleModel loanSchedule = loan.regenerateScheduleModel(scheduleGeneratorDTO);
if (loanSchedule == null) {
return;
}
loan.updateLoanSchedule(loanSchedule);
final Set<LoanCharge> charges = loan.getActiveCharges();
for (final LoanCharge loanCharge : charges) {
if (!loanCharge.isWaived()) {
loanChargeService.recalculateLoanCharge(loan, loanCharge, scheduleGeneratorDTO.getPenaltyWaitPeriod());
}
}
}
public void recalculateScheduleFromLastTransaction(final Loan loan, final ScheduleGeneratorDTO generatorDTO) {
if (loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) {
regenerateRepaymentScheduleWithInterestRecalculation(loan, generatorDTO);
} else {
regenerateRepaymentSchedule(loan, generatorDTO);
}
loan.reprocessTransactions();
}
public ChangedTransactionDetail recalculateScheduleFromLastTransaction(final Loan loan, final ScheduleGeneratorDTO generatorDTO,
final List<Long> existingTransactionIds, final List<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(loan.findExistingTransactionIds());
existingReversedTransactionIds.addAll(loan.findExistingReversedTransactionIds());
/*
* LocalDate recalculateFrom = null; List<LoanTransaction> loanTransactions =
* this.retrieveListOfTransactionsPostDisbursementExcludeAccruals(); for (LoanTransaction loanTransaction :
* loanTransactions) { if (recalculateFrom == null ||
* loanTransaction.getTransactionDate().isAfter(recalculateFrom)) { recalculateFrom =
* loanTransaction.getTransactionDate(); } } generatorDTO.setRecalculateFrom(recalculateFrom);
*/
if (loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) {
regenerateRepaymentScheduleWithInterestRecalculation(loan, generatorDTO);
} else {
regenerateRepaymentSchedule(loan, generatorDTO);
}
return loan.reprocessTransactions();
}
public void regenerateRepaymentScheduleWithInterestRecalculation(final Loan loan, final ScheduleGeneratorDTO generatorDTO) {
final LocalDate lastTransactionDate = loan.getLastUserTransactionDate();
final LoanScheduleDTO loanSchedule = loan.getRecalculatedSchedule(generatorDTO);
if (loanSchedule == null) {
return;
}
// Either the installments got recalculated or the model
if (loanSchedule.getInstallments() != null) {
loan.updateLoanSchedule(loanSchedule.getInstallments());
} else {
loan.updateLoanSchedule(loanSchedule.getLoanScheduleModel());
}
loan.setInterestRecalculatedOn(DateUtils.getBusinessLocalDate());
final LocalDate lastRepaymentDate = loan.getLastRepaymentPeriodDueDate(true);
final Set<LoanCharge> charges = loan.getActiveCharges();
for (final LoanCharge loanCharge : charges) {
if (!loanCharge.isDueAtDisbursement()) {
loan.updateOverdueScheduleInstallment(loanCharge);
if (loanCharge.getDueLocalDate() == null || !DateUtils.isBefore(lastRepaymentDate, loanCharge.getDueLocalDate())) {
if ((loanCharge.isInstalmentFee() || !loanCharge.isWaived()) && (loanCharge.getDueLocalDate() == null
|| !DateUtils.isAfter(lastTransactionDate, loanCharge.getDueLocalDate()))) {
loanChargeService.recalculateLoanCharge(loan, loanCharge, generatorDTO.getPenaltyWaitPeriod());
loanCharge.updateWaivedAmount(loan.getCurrency());
}
} else {
loanCharge.setActive(false);
}
}
}
loan.processPostDisbursementTransactions();
}
public ChangedTransactionDetail handleRegenerateRepaymentScheduleWithInterestRecalculation(final Loan loan,
final ScheduleGeneratorDTO generatorDTO) {
regenerateRepaymentScheduleWithInterestRecalculation(loan, generatorDTO);
return loan.reprocessTransactions();
}
}