diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index e56edebdf..6b030c295 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -3423,7 +3423,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { boolean isOverpaid = getTotalOverpaid() != null && getTotalOverpaid().compareTo(BigDecimal.ZERO) > 0; if (isOverpaid) { // FIXME - kw - update account balance to negative amount. - handleLoanOverpayment(loanLifecycleStateMachine); + handleLoanOverpayment(transactionDate, loanLifecycleStateMachine); statusChanged = true; } else if (this.summary.isRepaidInFull(loanCurrency())) { handleLoanRepaymentInFull(transactionDate, loanLifecycleStateMachine); @@ -3536,10 +3536,9 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { incomeDetailsMap.put(PENALTY, penalty); } - private void handleLoanOverpayment(final LoanLifecycleStateMachine loanLifecycleStateMachine) { - + private void handleLoanOverpayment(LocalDate transactionDate, final LoanLifecycleStateMachine loanLifecycleStateMachine) { + this.overpaidOnDate = transactionDate; loanLifecycleStateMachine.transition(LoanEvent.LOAN_OVERPAYMENT, this); - this.overpaidOnDate = DateUtils.getBusinessLocalDate(); this.closedOnDate = null; this.actualMaturityDate = null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java index 1eb1bb464..976597686 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java @@ -99,5 +99,5 @@ public interface LoanAccountDomainService { LoanTransaction creditBalanceRefund(Loan loan, LocalDate transactionDate, BigDecimal transactionAmount, String noteText, ExternalId externalId, PaymentDetail paymentDetail); - void applyIncomeAccrualTransaction(Loan loan); + void applyFinalIncomeAccrualTransaction(Loan loan); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java index 6a0b36d43..b7a6e3dc8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java @@ -877,7 +877,7 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService { } @Override - public void applyIncomeAccrualTransaction(Loan loan) { + public void applyFinalIncomeAccrualTransaction(Loan loan) { if (loan.isPeriodicAccrualAccountingEnabledOnLoanProduct() // to avoid collision with processIncomeAccrualTransactionOnLoanClosure() && !(loan.getLoanInterestRecalculationDetails() != null @@ -904,7 +904,9 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService { if (total.isGreaterThanZero()) { ExternalId externalId = externalIdFactory.create(); - LoanTransaction accrualTransaction = LoanTransaction.accrueTransaction(loan, loan.getOffice(), loan.getClosedOnDate(), + LocalDate accrualTransactionDate = getFinalAccrualTransactionDate(loan); + + LoanTransaction accrualTransaction = LoanTransaction.accrueTransaction(loan, loan.getOffice(), accrualTransactionDate, total.getAmount(), interestPortion.getAmount(), feePortion.getAmount(), penaltyPortion.getAmount(), externalId); Set accrualCharges = accrualTransaction.getLoanChargesPaid(); @@ -967,4 +969,12 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService { } } + private LocalDate getFinalAccrualTransactionDate(Loan loan) { + return switch (loan.getStatus()) { + case CLOSED_OBLIGATIONS_MET -> loan.getClosedOnDate(); + case OVERPAID -> loan.getOverpaidOnDate(); + default -> throw new IllegalStateException("Unexpected value: " + loan.getStatus()); + }; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java index 4f3dab667..14e379ea2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java @@ -47,9 +47,9 @@ public class LoanStatusChangePlatformServiceImpl implements LoanStatusChangePlat public void onBusinessEvent(LoanStatusChangedBusinessEvent event) { final Loan loan = event.get(); log.debug("Loan Status change for loan {}", loan.getId()); - if (loan.getStatus().isClosedObligationsMet()) { + if (loan.getStatus().isClosedObligationsMet() || loan.getStatus().isOverpaid()) { log.debug("Loan Status {} for loan {}", loan.getStatus().getCode(), loan.getId()); - loanAccountDomainService.applyIncomeAccrualTransaction(loan); + loanAccountDomainService.applyFinalIncomeAccrualTransaction(loan); } if (loan.isOpen()) { loan.handleMaturityDateActivate(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 97bd28814..b5e6145eb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -1229,6 +1229,19 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf LoanTransaction loanTransaction = this.loanTransactionRepository.findByIdAndLoanId(command.entityId(), command.getLoanId()) .orElseThrow(() -> new LoanTransactionNotFoundException(command.entityId(), command.getLoanId())); + if (loanTransaction.isReversed()) { + throw new PlatformServiceUnavailableException("error.msg.loan.chargeback.operation.not.allowed", + "Loan transaction:" + transactionId + " chargeback not allowed as loan transaction repayment is reversed", + transactionId); + } + + if (!loanTransaction.isRepaymentLikeType()) { + throw new PlatformServiceUnavailableException("error.msg.loan.chargeback.operation.not.allowed", + "Loan transaction:" + transactionId + " chargeback not allowed as loan transaction is not repayment type, its type is " + + loanTransaction.getTypeOf().getCode(), + transactionId); + } + Loan loan = this.loanAssembler.assembleFrom(loanId); if (this.accountTransfersReadPlatformService.isAccountTransfer(transactionId, PortfolioAccountType.LOAN)) { throw new PlatformServiceUnavailableException("error.msg.loan.transfer.transaction.update.not.allowed", @@ -1243,22 +1256,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf "Loan transaction:" + transactionId + " chargeback not allowed as loan product is interest recalculation enabled", transactionId); } - checkClientOrGroupActive(loan); - if (loanTransaction.isReversed()) { - throw new PlatformServiceUnavailableException("error.msg.loan.chargeback.operation.not.allowed", - "Loan transaction:" + transactionId + " chargeback not allowed as loan transaction repayment is reversed", - transactionId); - } - - if (!loanTransaction.isRepayment()) { - throw new PlatformServiceUnavailableException( - "error.msg.loan.chargeback.operation.not.allowed", "Loan transaction:" + transactionId - + " chargeback not allowed as loan transaction is not repayment, is " + loanTransaction.getTypeOf().getCode(), - transactionId); - } - final List existingTransactionIds = loan.findExistingTransactionIds(); final List existingReversedTransactionIds = loan.findExistingReversedTransactionIds(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DueDateRespectiveLoanRepaymentScheduleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DueDateRespectiveLoanRepaymentScheduleTest.java index d2629528b..4933ef598 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DueDateRespectiveLoanRepaymentScheduleTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DueDateRespectiveLoanRepaymentScheduleTest.java @@ -610,17 +610,25 @@ public class DueDateRespectiveLoanRepaymentScheduleTest { assertEquals(100.0, response.getTotalOverpaid()); assertTrue(response.getStatus().getOverpaid()); - assertEquals(secondRepaymentId, response.getTransactions().get(2).getId().intValue()); - assertNull(response.getTransactions().get(2).getReversedOnDate()); - assertTrue(response.getTransactions().get(2).getTransactionRelations().isEmpty()); - assertTrue(response.getTransactions().get(2).getType().getRepayment()); - assertEquals(650.0, response.getTransactions().get(2).getAmount()); - assertEquals(550.0, response.getTransactions().get(2).getPrincipalPortion()); - assertEquals(0.0, response.getTransactions().get(2).getPenaltyChargesPortion()); - assertEquals(100.0, response.getTransactions().get(2).getOverpaymentPortion()); - assertEquals(0.0, response.getTransactions().get(2).getInterestPortion()); - assertEquals(0.0, response.getTransactions().get(2).getFeeChargesPortion()); - assertEquals(0.0, response.getTransactions().get(2).getOutstandingLoanBalance()); + int secondRepaymentIndex; + // The repayment and accrual order is not consistent + if (response.getTransactions().get(2).getType().getRepayment()) { + secondRepaymentIndex = 2; + } else { + secondRepaymentIndex = 3; + } + + assertEquals(secondRepaymentId, response.getTransactions().get(secondRepaymentIndex).getId().intValue()); + assertNull(response.getTransactions().get(secondRepaymentIndex).getReversedOnDate()); + assertTrue(response.getTransactions().get(secondRepaymentIndex).getTransactionRelations().isEmpty()); + assertTrue(response.getTransactions().get(secondRepaymentIndex).getType().getRepayment()); + assertEquals(650.0, response.getTransactions().get(secondRepaymentIndex).getAmount()); + assertEquals(550.0, response.getTransactions().get(secondRepaymentIndex).getPrincipalPortion()); + assertEquals(0.0, response.getTransactions().get(secondRepaymentIndex).getPenaltyChargesPortion()); + assertEquals(100.0, response.getTransactions().get(secondRepaymentIndex).getOverpaymentPortion()); + assertEquals(0.0, response.getTransactions().get(secondRepaymentIndex).getInterestPortion()); + assertEquals(0.0, response.getTransactions().get(secondRepaymentIndex).getFeeChargesPortion()); + assertEquals(0.0, response.getTransactions().get(secondRepaymentIndex).getOutstandingLoanBalance()); } finally { GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.FALSE); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountOverpaidDateStatusTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountOverpaidDateStatusTest.java index 63b9f593d..72f169d28 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountOverpaidDateStatusTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountOverpaidDateStatusTest.java @@ -30,8 +30,6 @@ import io.restassured.http.ContentType; import io.restassured.specification.RequestSpecification; import io.restassured.specification.ResponseSpecification; import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeFormatterBuilder; import java.util.HashMap; import java.util.UUID; import org.apache.fineract.client.models.GetDelinquencyBucketsResponse; @@ -57,12 +55,9 @@ import org.junit.jupiter.api.extension.ExtendWith; public class LoanAccountOverpaidDateStatusTest { private ResponseSpecification responseSpec; - private ResponseSpecification responseSpecErr400; - private ResponseSpecification responseSpecErr503; private RequestSpecification requestSpec; private ClientHelper clientHelper; private LoanTransactionHelper loanTransactionHelper; - private DateTimeFormatter dateFormatter = new DateTimeFormatterBuilder().appendPattern("dd MMMM yyyy").toFormatter(); @BeforeEach public void setup() { @@ -70,8 +65,6 @@ public class LoanAccountOverpaidDateStatusTest { this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); - this.responseSpecErr400 = new ResponseSpecBuilder().expectStatusCode(400).build(); - this.responseSpecErr503 = new ResponseSpecBuilder().expectStatusCode(503).build(); this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec); this.clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); } @@ -124,7 +117,7 @@ public class LoanAccountOverpaidDateStatusTest { GetLoansLoanIdResponse loanDetailsOverpaid = loanTransactionHelper.getLoanDetails((long) loanId); assertTrue(loanDetailsOverpaid.getStatus().getOverpaid()); assertNotNull(loanDetailsOverpaid.getOverpaidOnDate()); - assertEquals(loanDetailsOverpaid.getOverpaidOnDate(), todaysDate); + assertEquals(loanDetailsOverpaid.getOverpaidOnDate(), LocalDate.of(2022, 9, 9)); // reverse repayment to make loan not overpaid and overpaid date is reset loanTransactionHelper.reverseRepayment(loanId, repaymentTransaction_4.getResourceId().intValue(), "10 September 2022"); @@ -141,7 +134,7 @@ public class LoanAccountOverpaidDateStatusTest { GetLoansLoanIdResponse loanDetailsOverpaid_1 = loanTransactionHelper.getLoanDetails((long) loanId); assertTrue(loanDetailsOverpaid_1.getStatus().getOverpaid()); assertNotNull(loanDetailsOverpaid_1.getOverpaidOnDate()); - assertEquals(loanDetailsOverpaid_1.getOverpaidOnDate(), todaysDate); + assertEquals(loanDetailsOverpaid_1.getOverpaidOnDate(), LocalDate.of(2022, 9, 11)); // Credit balance refund to reset overpaid status loanTransactionHelper.creditBalanceRefund("12 September 2022", Float.valueOf(100), null, loanId, ""); @@ -164,7 +157,7 @@ public class LoanAccountOverpaidDateStatusTest { GetLoansLoanIdResponse loanDetailsOverpaid_3 = loanTransactionHelper.getLoanDetails((long) loanId); assertTrue(loanDetailsOverpaid_3.getStatus().getOverpaid()); assertNotNull(loanDetailsOverpaid_3.getOverpaidOnDate()); - assertEquals(loanDetailsOverpaid_3.getOverpaidOnDate(), todaysDate); + assertEquals(loanDetailsOverpaid_3.getOverpaidOnDate(), LocalDate.of(2022, 9, 14)); } finally { GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.FALSE); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeSpecificDueDateTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeSpecificDueDateTest.java index 330478d93..d52f64dc9 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeSpecificDueDateTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeSpecificDueDateTest.java @@ -427,6 +427,84 @@ public class LoanChargeSpecificDueDateTest { } } + @Test + public void testApplyFeeAccrualWhenLoanOverpaid() { + try { + GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.TRUE); + + final LocalDate todaysDate = Utils.getLocalDateOfTenant(); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, todaysDate); + + // Client and Loan account creation + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2012"); + final GetLoanProductsProductIdResponse getLoanProductsProductResponse = createLoanProductWithPeriodicAccrual( + loanTransactionHelper, null); + assertNotNull(getLoanProductsProductResponse); + + LocalDate transactionDate = LocalDate.of(Utils.getLocalDateOfTenant().getYear(), 1, 1); + String operationDate = Utils.dateFormatter.format(transactionDate); + log.info("Disbursement date {}", transactionDate); + + // Create Loan Account + final Integer loanId = createLoanAccount(loanTransactionHelper, clientId.toString(), + getLoanProductsProductResponse.getId().toString(), operationDate, "1", "0"); + + // Get loan details + GetLoansLoanIdResponse getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + validateLoanAccount(getLoansLoanIdResponse, Double.valueOf(principalAmount), Double.valueOf("0.00"), true); + + // Apply Loan Charge with specific due date + String feeAmount = "10.00"; + String payloadJSON = ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, feeAmount, true); + final PostChargesResponse postChargesResponse = ChargesHelper.createLoanCharge(requestSpec, responseSpec, payloadJSON); + assertNotNull(postChargesResponse); + final Long chargeId = postChargesResponse.getResourceId(); + assertNotNull(chargeId); + + // First Loan Charge + transactionDate = transactionDate.plusDays(1); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, transactionDate); + operationDate = Utils.dateFormatter.format(transactionDate); + log.info("Operation date {}", transactionDate); + payloadJSON = LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(chargeId.toString(), operationDate, feeAmount); + PostLoansLoanIdChargesResponse postLoansLoanIdChargesResponse = loanTransactionHelper.addChargeForLoan(loanId, payloadJSON, + responseSpec); + assertNotNull(postLoansLoanIdChargesResponse); + final Long loanChargeId01 = postLoansLoanIdChargesResponse.getResourceId(); + assertNotNull(loanChargeId01); + + Float amount = Float.valueOf("1020.00"); + transactionDate = transactionDate.plusDays(2); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, transactionDate); + operationDate = Utils.dateFormatter.format(transactionDate); + log.info("Operation date {}", transactionDate); + PostLoansLoanIdTransactionsResponse loanIdTransactionsResponse = loanTransactionHelper.makeLoanRepayment(operationDate, amount, + loanId); + assertNotNull(loanIdTransactionsResponse); + log.info("Loan Transaction Id: {} {}", loanId, loanIdTransactionsResponse.getResourceId()); + + getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(getLoansLoanIdResponse); + loanTransactionHelper.validateLoanStatus(getLoansLoanIdResponse, "loanStatusType.overpaid"); + + final Long transactionId = loanTransactionHelper.evaluateLastLoanTransactionData(getLoansLoanIdResponse, + "loanTransactionType.accrual", operationDate, Double.valueOf("10.00")); + assertNotNull(transactionId); + log.info("transactionId {}", transactionId); + + final GetJournalEntriesTransactionIdResponse journalEntriesResponse = journalEntryHelper.getJournalEntries("L" + transactionId); + assertNotNull(journalEntriesResponse); + final List journalEntries = journalEntriesResponse.getPageItems(); + assertEquals(2, journalEntries.size()); + assertEquals(10, journalEntries.get(0).getAmount()); + assertEquals(10, journalEntries.get(1).getAmount()); + assertEquals(transactionDate, journalEntries.get(0).getTransactionDate()); + assertEquals(transactionDate, journalEntries.get(1).getTransactionDate()); + } finally { + GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.FALSE); + } + } + private GetLoanProductsProductIdResponse createLoanProduct(final LoanTransactionHelper loanTransactionHelper, final Integer delinquencyBucketId) { final HashMap loanProductMap = new LoanProductTestBuilder().build(null, delinquencyBucketId);