From d1168833ccd1be1a5f5e4e75e2d9064300cfd17e Mon Sep 17 00:00:00 2001 From: John Woodlock Date: Thu, 14 Apr 2022 14:29:36 +0100 Subject: [PATCH] Credit Balance Refund loan transaction --- ...ccrualBasedAccountingProcessorForLoan.java | 24 ++ .../service/CommandWrapperBuilder.java | 9 + .../BusinessEventNotificationConstants.java | 1 + .../api/LoanTransactionsApiResource.java | 5 + .../data/LoanTransactionEnumData.java | 6 + .../DefaultLoanLifecycleStateMachine.java | 2 + .../portfolio/loanaccount/domain/Loan.java | 58 +++- .../domain/LoanAccountDomainService.java | 3 + .../domain/LoanAccountDomainServiceJpa.java | 36 ++ .../loanaccount/domain/LoanEvent.java | 3 +- .../loanaccount/domain/LoanTransaction.java | 19 ++ .../domain/LoanTransactionType.java | 6 +- .../CreditBalanceRefundCommandHandler.java | 72 ++++ .../LoanEventApiJsonValidator.java | 3 + .../service/LoanReadPlatformService.java | 2 + .../service/LoanReadPlatformServiceImpl.java | 34 +- .../service/LoanWritePlatformService.java | 2 + ...WritePlatformServiceJpaRepositoryImpl.java | 33 +- .../loanproduct/service/LoanEnumerations.java | 4 + .../db/changelog/tenant/changelog-tenant.xml | 1 + ...1_add_credit_balance_refund_permission.xml | 32 ++ ...oanCreditBalanceRefundIntegrationTest.java | 307 ++++++++++++++++++ ...MultipleDisbursementsIntegrationTest.java} | 4 +- .../common/loans/LoanTransactionHelper.java | 26 ++ 24 files changed, 671 insertions(+), 21 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/CreditBalanceRefundCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0011_add_credit_balance_refund_permission.xml create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanCreditBalanceRefundIntegrationTest.java rename integration-tests/src/test/java/org/apache/fineract/integrationtests/{ClientLoanIntegrationMultipleDisbursementsTest.java => ClientLoanMultipleDisbursementsIntegrationTest.java} (99%) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java index b49baec14..330651ac8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java @@ -80,6 +80,11 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess createJournalEntriesForRefund(loanDTO, loanTransactionDTO, office); } + /** Logic for Credit Balance Refunds **/ + else if (loanTransactionDTO.getTransactionType().isCreditBalanceRefund()) { + createJournalEntriesForCreditBalanceRefund(loanDTO, loanTransactionDTO, office); + } + /** Handle Write Offs, waivers and their reversals **/ else if ((loanTransactionDTO.getTransactionType().isWriteOff() || loanTransactionDTO.getTransactionType().isWaiveInterest() || loanTransactionDTO.getTransactionType().isWaiveCharges())) { @@ -397,6 +402,25 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess } } + private void createJournalEntriesForCreditBalanceRefund(final LoanDTO loanDTO, final LoanTransactionDTO loanTransactionDTO, + final Office office) { + // loan properties + final Long loanProductId = loanDTO.getLoanProductId(); + final Long loanId = loanDTO.getLoanId(); + final String currencyCode = loanDTO.getCurrencyCode(); + + // transaction properties + final String transactionId = loanTransactionDTO.getTransactionId(); + final Date transactionDate = loanTransactionDTO.getTransactionDate(); + final BigDecimal refundAmount = loanTransactionDTO.getAmount(); + final boolean isReversal = loanTransactionDTO.isReversed(); + final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId(); + + this.helper.createAccrualBasedJournalEntriesAndReversalsForLoan(office, currencyCode, + AccrualAccountsForLoan.LOAN_PORTFOLIO.getValue(), AccrualAccountsForLoan.OVERPAYMENT.getValue(), loanProductId, + paymentTypeId, loanId, transactionId, transactionDate, refundAmount, isReversal); + } + private void createJournalEntriesForRefundForActiveLoan(LoanDTO loanDTO, LoanTransactionDTO loanTransactionDTO, Office office) { // TODO Auto-generated method stub // loan properties diff --git a/fineract-provider/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-provider/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index b21dfa86b..775cd84e1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-provider/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -878,6 +878,15 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder creditBalanceRefund(final Long loanId) { + this.actionName = "CREDITBALANCEREFUND"; + this.entityName = "LOAN"; + this.entityId = null; + this.loanId = loanId; + this.href = "/loans/" + loanId + "/transactions?command=creditBalanceRefund"; + return this; + } + public CommandWrapperBuilder undoWaiveChargeTransaction(final Long loanId, final Long transactionId) { this.actionName = "UNDO"; this.entityName = "WAIVECHARGE"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/common/BusinessEventNotificationConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/common/BusinessEventNotificationConstants.java index f101a56a4..4c4af1cf1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/common/BusinessEventNotificationConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/common/BusinessEventNotificationConstants.java @@ -55,6 +55,7 @@ public class BusinessEventNotificationConstants { LOAN_INTEREST_RECALCULATION("loan_interest_recalculation"), // LOAN_REFUND("loan_refund"), // LOAN_FORECLOSURE("loan_foreclosure"), // + LOAN_CREDIT_BALANCE_REFUND("loan_credit_balance_refund"), // LOAN_CREATE("loan_create"), // LOAN_PRODUCT_CREATE("loan_product_create"), // SAVINGS_ACTIVATE("savings_activated"), // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java index 2e536e209..7430fe1a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java @@ -164,6 +164,8 @@ public class LoanTransactionsApiResource { DateUtils.getDateTimeZoneOfTenant()); } transactionData = this.loanReadPlatformService.retrieveLoanForeclosureTemplate(loanId, transactionDate); + } else if (is(commandParam, "creditBalanceRefund")) { + transactionData = this.loanReadPlatformService.retrieveCreditBalanceRefundTemplate(loanId); } else { throw new UnrecognizedQueryParamException("command", commandParam); } @@ -239,6 +241,9 @@ public class LoanTransactionsApiResource { } else if (is(commandParam, "foreclosure")) { final CommandWrapper commandRequest = builder.loanForeclosure(loanId).build(); result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } else if (is(commandParam, "creditBalanceRefund")) { + final CommandWrapper commandRequest = builder.creditBalanceRefund(loanId).build(); + result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } if (result == null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java index f970eb518..1cf00e9e3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java @@ -44,6 +44,7 @@ public class LoanTransactionEnumData { private final boolean chargePayment; private final boolean refund; private final boolean refundForActiveLoans; + private final boolean creditBalanceRefund; public LoanTransactionEnumData(final Long id, final String code, final String value) { this.id = id; @@ -65,6 +66,7 @@ public class LoanTransactionEnumData { this.refund = Long.valueOf(16).equals(this.id); this.chargePayment = Long.valueOf(17).equals(this.id); this.refundForActiveLoans = Long.valueOf(18).equals(this.id); + this.creditBalanceRefund = Long.valueOf(20).equals(this.id); } public Long id() { @@ -152,4 +154,8 @@ public class LoanTransactionEnumData { return this.refundForActiveLoans; } + public boolean isCreditBalanceRefund() { + return this.creditBalanceRefund; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/DefaultLoanLifecycleStateMachine.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/DefaultLoanLifecycleStateMachine.java index 0189dd4fb..1da59cc4c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/DefaultLoanLifecycleStateMachine.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/DefaultLoanLifecycleStateMachine.java @@ -106,6 +106,8 @@ public class DefaultLoanLifecycleStateMachine implements LoanLifecycleStateMachi break; case WRITE_OFF_OUTSTANDING_UNDO: break; + case LOAN_CREDIT_BALANCE_REFUND: + break; default: break; } 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 4ac8c2528..dbf7680f2 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 @@ -3615,10 +3615,11 @@ public class Loan extends AbstractPersistableCustom { validateActivityNotBeforeClientOrGroupTransferDate(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, transactionForAdjustment.getTransactionDate()); - if (transactionForAdjustment.isNotRepayment() && transactionForAdjustment.isNotWaiver()) { - final String errorMessage = "Only transactions of type repayment or waiver can be adjusted."; - throw new InvalidLoanTransactionTypeException("transaction", "adjustment.is.only.allowed.to.repayment.or.waiver.transaction", - errorMessage); + if (transactionForAdjustment.isNotRepayment() && transactionForAdjustment.isNotWaiver() + && transactionForAdjustment.isNotCreditBalanceRefund()) { + final String errorMessage = "Only transactions of type repayment, waiver or credit balance refund can be adjusted."; + throw new InvalidLoanTransactionTypeException("transaction", + "adjustment.is.only.allowed.to.repayment.or.waiver.or.creditbalancerefund.transactions", errorMessage); } transactionForAdjustment.reverse(); @@ -3698,7 +3699,8 @@ public class Loan extends AbstractPersistableCustom { } for (final LoanTransaction loanTransaction : this.loanTransactions) { - if ((loanTransaction.isRefund() || loanTransaction.isRefundForActiveLoan()) && !loanTransaction.isReversed()) { + if ((loanTransaction.isRefund() || loanTransaction.isRefundForActiveLoan() || loanTransaction.isCreditBalanceRefund()) + && !loanTransaction.isReversed()) { totalPaidInRepayments = totalPaidInRepayments.minus(loanTransaction.getAmount(currency)); } } @@ -5157,6 +5159,14 @@ public class Loan extends AbstractPersistableCustom { dataValidationErrors.add(error); } break; + case LOAN_CREDIT_BALANCE_REFUND: + if (!status().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); + } + break; default: break; } @@ -6062,6 +6072,40 @@ public class Loan extends AbstractPersistableCustom { return this.guaranteeAmountDerived == null ? BigDecimal.ZERO : this.guaranteeAmountDerived; } + public void creditBalanceRefund(LoanTransaction newCreditBalanceRefundTransaction, + LoanLifecycleStateMachine defaultLoanLifecycleStateMachine, List existingTransactionIds, + List existingReversedTransactionIds) { + validateAccountStatus(LoanEvent.LOAN_CREDIT_BALANCE_REFUND); + + validateRefundDateIsAfterLastRepayment(newCreditBalanceRefundTransaction.getTransactionDate()); + + if (!newCreditBalanceRefundTransaction.isGreaterThanZeroAndLessThanOrEqualTo(this.totalOverpaid)) { + final String errorMessage = "Transaction Amount (" + + newCreditBalanceRefundTransaction.getAmount(getCurrency()).getAmount().toString() + + ") must be > zero and <= Overpaid amount (" + this.totalOverpaid.toString() + ")."; + final List dataValidationErrors = new ArrayList<>(); + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg.transactionAmount.invalid.must.be.>zero.and<=overpaidamount", errorMessage, "transactionAmount", + newCreditBalanceRefundTransaction.getAmount(getCurrency())); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + + existingTransactionIds.addAll(findExistingTransactionIds()); + existingReversedTransactionIds.addAll(findExistingReversedTransactionIds()); + + this.loanTransactions.add(newCreditBalanceRefundTransaction); + + updateLoanSummaryDerivedFields(); + + if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) { + this.loanStatus = LoanStatus.CLOSED_OBLIGATIONS_MET.getValue(); + } + + } + public ChangedTransactionDetail makeRefundForActiveLoan(final LoanTransaction loanTransaction, final LoanLifecycleStateMachine loanLifecycleStateMachine, final List existingTransactionIds, final List existingReversedTransactionIds, final boolean allowTransactionsOnHoliday, final List holidays, @@ -6179,7 +6223,8 @@ public class Loan extends AbstractPersistableCustom { LocalDate lastTransactionDate = null; for (final LoanTransaction transaction : this.loanTransactions) { - if ((transaction.isRepayment() || transaction.isRefundForActiveLoan()) && transaction.isNonZero()) { + if ((transaction.isRepayment() || transaction.isRefundForActiveLoan() || transaction.isCreditBalanceRefund()) + && transaction.isNonZero() && transaction.isNotReversed()) { lastTransactionDate = transaction.getTransactionDate(); } } @@ -6841,4 +6886,5 @@ public class Loan extends AbstractPersistableCustom { public void adjustNetDisbursalAmount(BigDecimal adjustedAmount) { this.netDisbursalAmount = adjustedAmount.subtract(this.deriveSumTotalOfChargesDueAtDisbursement()); } + } 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 71db423b9..29819a3e2 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 @@ -78,4 +78,7 @@ public interface LoanAccountDomainService { void disableStandingInstructionsLinkedToClosedLoan(Loan loan); void recalculateAccruals(Loan loan, boolean isInterestCalcualtionHappened); + + CommandProcessingResultBuilder creditBalanceRefund(Long loanId, LocalDate transactionDate, BigDecimal transactionAmount, + String noteText, String externalId); } 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 2a2aa6f68..cf6c62775 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 @@ -623,6 +623,41 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService { return user; } + @Override + public CommandProcessingResultBuilder creditBalanceRefund(Long loanId, LocalDate transactionDate, BigDecimal transactionAmount, + String noteText, String externalId) { + final Loan loan = this.loanAccountAssembler.assembleFrom(loanId); + this.businessEventNotifierService.notifyBusinessEventToBeExecuted(BusinessEvents.LOAN_CREDIT_BALANCE_REFUND, + constructEntityMap(BusinessEntity.LOAN, loan)); + final List existingTransactionIds = new ArrayList<>(); + final List existingReversedTransactionIds = new ArrayList<>(); + AppUser currentUser = getAppUserIfPresent(); + + final Money refundAmount = Money.of(loan.getCurrency(), transactionAmount); + final LoanTransaction newCreditBalanceRefundTransaction = LoanTransaction.creditBalanceRefund(loan, loan.getOffice(), refundAmount, + transactionDate, externalId, DateUtils.getLocalDateTimeOfTenant(), currentUser); + + loan.creditBalanceRefund(newCreditBalanceRefundTransaction, defaultLoanLifecycleStateMachine(), existingTransactionIds, + existingReversedTransactionIds); + + this.loanTransactionRepository.saveAndFlush(newCreditBalanceRefundTransaction); + + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.loanTransactionNote(loan, newCreditBalanceRefundTransaction, noteText); + this.noteRepository.save(note); + } + + postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds, false); + recalculateAccruals(loan); + this.businessEventNotifierService.notifyBusinessEventWasExecuted(BusinessEvents.LOAN_CREDIT_BALANCE_REFUND, + constructEntityMap(BusinessEntity.LOAN_TRANSACTION, newCreditBalanceRefundTransaction)); + + return new CommandProcessingResultBuilder().withEntityId(newCreditBalanceRefundTransaction.getId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withGroupId(loan.getGroupId()); + } + @Override public LoanTransaction makeRefundForActiveLoan(Long accountId, CommandProcessingResultBuilder builderResult, LocalDate transactionDate, BigDecimal transactionAmount, PaymentDetail paymentDetail, String noteText, String txnExternalId) { @@ -794,4 +829,5 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService { } } } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanEvent.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanEvent.java index fe4601759..7db08e3b1 100755 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanEvent.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanEvent.java @@ -43,5 +43,6 @@ public enum LoanEvent { LOAN_CLOSED, // LOAN_EDIT_MULTI_DISBURSE_DATE, // LOAN_REFUND, // - LOAN_FORECLOSURE; + LOAN_FORECLOSURE, // + LOAN_CREDIT_BALANCE_REFUND; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index bc1970fac..351566a2f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -297,6 +297,13 @@ public class LoanTransaction extends AbstractPersistableCustom { return applyCharge; } + public static LoanTransaction creditBalanceRefund(final Loan loan, final Office office, final Money amount, final LocalDate paymentDate, + final String externalId, final LocalDateTime createdDate, final AppUser appUser) { + final PaymentDetail paymentDetail = null; + return new LoanTransaction(loan, office, LoanTransactionType.CREDIT_BALANCE_REFUND, paymentDetail, amount.getAmount(), paymentDate, + externalId, createdDate, appUser); + } + public static LoanTransaction refundForActiveLoan(final Office office, final Money amount, final PaymentDetail paymentDetail, final LocalDate paymentDate, final String externalId, final LocalDateTime createdDate, final AppUser appUser) { return new LoanTransaction(null, office, LoanTransactionType.REFUND_FOR_ACTIVE_LOAN, paymentDetail, amount.getAmount(), paymentDate, @@ -573,6 +580,10 @@ public class LoanTransaction extends AbstractPersistableCustom { return !isInterestWaiver() && !isChargesWaiver(); } + public boolean isNotCreditBalanceRefund() { + return !isCreditBalanceRefund(); + } + public boolean isChargePayment() { return getTypeOf().isChargePayment() && isNotReversed(); } @@ -616,6 +627,10 @@ public class LoanTransaction extends AbstractPersistableCustom { return getAmount(currency).isGreaterThanZero(); } + public boolean isGreaterThanZeroAndLessThanOrEqualTo(BigDecimal totalOverpaid) { + return isNonZero() && this.amount.compareTo(totalOverpaid) <= 0; + } + public boolean isNotZero(final MonetaryCurrency currency) { return !getAmount(currency).isZero(); } @@ -700,6 +715,10 @@ public class LoanTransaction extends AbstractPersistableCustom { return LoanTransactionType.REFUND.equals(getTypeOf()) && isNotReversed(); } + public boolean isCreditBalanceRefund() { + return LoanTransactionType.CREDIT_BALANCE_REFUND.equals(getTypeOf()) && isNotReversed(); + } + public void updateExternalId(final String externalId) { this.externalId = externalId; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java index b053327ab..c38bda6be 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java @@ -51,7 +51,8 @@ public enum LoanTransactionType { REFUND(16, "loanTransactionType.refund"), // CHARGE_PAYMENT(17, "loanTransactionType.chargePayment"), // REFUND_FOR_ACTIVE_LOAN(18, "loanTransactionType.refund"), // - INCOME_POSTING(19, "loanTransactionType.incomePosting"); + INCOME_POSTING(19, "loanTransactionType.incomePosting"), // + CREDIT_BALANCE_REFUND(20, "loanTransactionType.creditBalanceRefund"); private final Integer value; private final String code; @@ -131,6 +132,9 @@ public enum LoanTransactionType { case 19: loanTransactionType = LoanTransactionType.INCOME_POSTING; break; + case 20: + loanTransactionType = LoanTransactionType.CREDIT_BALANCE_REFUND; + break; default: loanTransactionType = LoanTransactionType.INVALID; break; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/CreditBalanceRefundCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/CreditBalanceRefundCommandHandler.java new file mode 100644 index 000000000..798290c51 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/CreditBalanceRefundCommandHandler.java @@ -0,0 +1,72 @@ +/** + * 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.handler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.portfolio.loanaccount.service.LoanWritePlatformService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +@CommandType(entity = "LOAN", action = "CREDITBALANCEREFUND") +public class CreditBalanceRefundCommandHandler implements NewCommandSourceHandler { + + private final LoanWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + try { + return this.writePlatformService.creditBalanceRefund(command.getLoanId(), command); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve, "loan.creditBalanceRefund", "Credit Balance Refund"); + return CommandProcessingResult.empty(); + } + } + + private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dve, final String msgType, + final String msgDescription) { + + if (realCause.getMessage().contains("external_id")) { + + final String externalId = command.stringValueOfParameterNamed("externalId"); + throw new PlatformDataIntegrityException("error.msg." + msgType + ".duplicate.externalId", + msgDescription + " with externalId `" + externalId + "` already exists", "externalId", externalId); + } + + logAsErrorUnexpectedDataIntegrityException(dve); + throw new PlatformDataIntegrityException("error.msg.loan.charge.unknown.data.integrity.issue", + "Unknown data integrity issue with resource."); + } + + private void logAsErrorUnexpectedDataIntegrityException(final Exception dve) { + log.error("Error occured.", dve); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanEventApiJsonValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanEventApiJsonValidator.java index a7186060b..a1b09c55d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanEventApiJsonValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanEventApiJsonValidator.java @@ -529,6 +529,9 @@ public final class LoanEventApiJsonValidator { final String note = this.fromApiJsonHelper.extractStringNamed("note", element); baseDataValidator.reset().parameter("note").value(note).notExceedingLengthOf(1000); + final String externalId = this.fromApiJsonHelper.extractStringNamed("externalId", element); + baseDataValidator.reset().parameter("externalId").value(externalId).notExceedingLengthOf(100); + validatePaymentDetails(baseDataValidator, element); throwExceptionIfValidationWarningsExist(dataValidationErrors); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java index 151793467..8618c33f0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformService.java @@ -128,6 +128,8 @@ public interface LoanReadPlatformService { LoanTransactionData retrieveRefundByCashTemplate(Long loanId); + LoanTransactionData retrieveCreditBalanceRefundTemplate(Long loanId); + Collection retrieveLoanInterestRatePeriodData(LoanAccountData loan); Collection retrieveLoanIdsWithPendingIncomePostingTransactions(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java index 0d8e30566..eca9a5b81 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java @@ -2118,24 +2118,38 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService { @Override public LoanTransactionData retrieveRefundByCashTemplate(Long loanId) { - // TODO Auto-generated method stub this.context.authenticatedUser(); - // TODO - KW - OPTIMIZE - write simple sql query to fetch back date of - // possible next transaction date. + final Collection paymentOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); final Loan loan = this.loanRepositoryWrapper.findOneWithNotFoundDetection(loanId, true); - final MonetaryCurrency currency = loan.getCurrency(); + return retrieveRefundTemplate(loanId, LoanTransactionType.REFUND_FOR_ACTIVE_LOAN, paymentOptions, loan.getCurrency(), + retrieveTotalPaidInAdvance(loan.getId()).getPaidInAdvance(), loan.getNetDisbursalAmount()); + } + + @Override + public LoanTransactionData retrieveCreditBalanceRefundTemplate(Long loanId) { + this.context.authenticatedUser(); + + final Collection paymentOptions = null; + final BigDecimal netDisbursal = null; + final Loan loan = this.loanRepositoryWrapper.findOneWithNotFoundDetection(loanId, true); + return retrieveRefundTemplate(loanId, LoanTransactionType.CREDIT_BALANCE_REFUND, paymentOptions, loan.getCurrency(), + loan.getTotalOverpaid(), netDisbursal); + + } + + private LoanTransactionData retrieveRefundTemplate(Long loanId, LoanTransactionType loanTransactionType, + Collection paymentOptions, MonetaryCurrency currency, BigDecimal transactionAmount, BigDecimal netDisbursal) { + final ApplicationCurrency applicationCurrency = this.applicationCurrencyRepository.findOneWithNotFoundDetection(currency); final CurrencyData currencyData = applicationCurrency.toData(); - final LocalDate earliestUnpaidInstallmentDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate currentDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - final LoanTransactionEnumData transactionType = LoanEnumerations.transactionType(LoanTransactionType.REFUND_FOR_ACTIVE_LOAN); - final Collection paymentOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); - return new LoanTransactionData(null, null, null, transactionType, null, currencyData, earliestUnpaidInstallmentDate, - retrieveTotalPaidInAdvance(loan.getId()).getPaidInAdvance(), null, loan.getNetDisbursalAmount(), null, null, null, null, - null, paymentOptions, null, null, null, null, false); + final LoanTransactionEnumData transactionType = LoanEnumerations.transactionType(loanTransactionType); + return new LoanTransactionData(null, null, null, transactionType, null, currencyData, currentDate, transactionAmount, null, + netDisbursal, null, null, null, null, null, paymentOptions, null, null, null, null, false); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformService.java index 9be2fdbe8..9c49ade84 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformService.java @@ -116,4 +116,6 @@ public interface LoanWritePlatformService { CommandProcessingResult makeGLIMLoanRepayment(Long loanId, JsonCommand command); + CommandProcessingResult creditBalanceRefund(Long loanId, JsonCommand command); + } 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 83ccfadfe..512242dee 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 @@ -3213,10 +3213,40 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf return installments; } + @Override + public CommandProcessingResult creditBalanceRefund(Long loanId, JsonCommand command) { + this.loanEventApiJsonValidator.validateNewRefundTransaction(command.json()); + + final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate"); + final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount"); + final String noteText = command.stringValueOfParameterNamedAllowingNull("note"); + final String externalId = command.stringValueOfParameterNamedAllowingNull("externalId"); + + final Map changes = new LinkedHashMap<>(); + changes.put("transactionDate", command.stringValueOfParameterNamed("transactionDate")); + changes.put("transactionAmount", command.stringValueOfParameterNamed("transactionAmount")); + changes.put("locale", command.locale()); + changes.put("dateFormat", command.dateFormat()); + + if (StringUtils.isNotBlank(noteText)) { + changes.put("note", noteText); + } + if (StringUtils.isNotBlank(externalId)) { + changes.put("externalId", externalId); + } + + final CommandProcessingResultBuilder commandProcessingResultBuilder = this.loanAccountDomainService.creditBalanceRefund(loanId, + transactionDate, transactionAmount, noteText, externalId); + + return commandProcessingResultBuilder // + .withCommandId(command.commandId()).with(changes) // + .build(); + + } + @Override @Transactional public CommandProcessingResult makeLoanRefund(Long loanId, JsonCommand command) { - // TODO Auto-generated method stub this.loanEventApiJsonValidator.validateNewRefundTransaction(command.json()); @@ -3400,4 +3430,5 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf } } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java index e2f8e525d..56faedcba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java @@ -411,6 +411,10 @@ public final class LoanEnumerations { optionData = new LoanTransactionEnumData(LoanTransactionType.INCOME_POSTING.getValue().longValue(), LoanTransactionType.INCOME_POSTING.getCode(), "Income Posting"); break; + case CREDIT_BALANCE_REFUND: + optionData = new LoanTransactionEnumData(LoanTransactionType.CREDIT_BALANCE_REFUND.getValue().longValue(), + LoanTransactionType.CREDIT_BALANCE_REFUND.getCode(), "Credit Balance Refund"); + break; } return optionData; } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 7dc81891f..e871a7f94 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -30,4 +30,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0011_add_credit_balance_refund_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0011_add_credit_balance_refund_permission.xml new file mode 100644 index 000000000..dd626b8d4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0011_add_credit_balance_refund_permission.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanCreditBalanceRefundIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanCreditBalanceRefundIntegrationTest.java new file mode 100644 index 000000000..91959773e --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanCreditBalanceRefundIntegrationTest.java @@ -0,0 +1,307 @@ +/** + * 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.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.util.ArrayList; +import java.util.HashMap; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.CommonConstants; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.accounting.AccountHelper; +import org.apache.fineract.integrationtests.common.accounting.JournalEntry; +import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper; +import org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanStatusChecker; +import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@SuppressWarnings({ "rawtypes", "unchecked" }) +public class ClientLoanCreditBalanceRefundIntegrationTest { + + private static final Logger LOG = LoggerFactory.getLogger(ClientLoanCreditBalanceRefundIntegrationTest.class); + + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + private LoanTransactionHelper loanTransactionHelper; + private LoanTransactionHelper loanTransactionHelperValidationError; + private JournalEntryHelper journalEntryHelper; + private AccountHelper accountHelper; + private Integer disbursedLoanID; + private static final String ACCRUAL_PERIODIC = "3"; + private Account assetAccount; + private Account incomeAccount; + private Account expenseAccount; + private Account overpaymentAccount; + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec); + this.loanTransactionHelperValidationError = new LoanTransactionHelper(this.requestSpec, new ResponseSpecBuilder().build()); + this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); + this.assetAccount = this.accountHelper.createAssetAccount(); + this.incomeAccount = this.accountHelper.createIncomeAccount(); + this.expenseAccount = this.accountHelper.createExpenseAccount(); + this.overpaymentAccount = this.accountHelper.createLiabilityAccount(); + this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec); + + final String principal = "12000.00"; + final String submitApproveDisburseDate = "01 January 2022"; + this.disbursedLoanID = fromStartToDisburseLoan(submitApproveDisburseDate, principal, ACCRUAL_PERIODIC, assetAccount, incomeAccount, + expenseAccount, overpaymentAccount); + + } + + private Integer createLoanProduct(final String principal, final boolean multiDisburseLoan, final String accountingRule, + final Account... accounts) { + LOG.info("------------------------------CREATING NEW LOAN PRODUCT ---------------------------------------"); + LoanProductTestBuilder builder = new LoanProductTestBuilder() // + .withPrincipal(principal) // + .withNumberOfRepayments("4") // + .withRepaymentAfterEvery("1") // + .withRepaymentTypeAsMonth() // + .withinterestRatePerPeriod("1") // + .withInterestRateFrequencyTypeAsMonths() // + .withAmortizationTypeAsEqualInstallments() // + .withInterestTypeAsDecliningBalance() // + .withAccounting(accountingRule, accounts) // + .withTranches(multiDisburseLoan); + if (multiDisburseLoan) { + builder = builder.withInterestCalculationPeriodTypeAsRepaymentPeriod(true); + builder = builder.withMaxTrancheCount("30"); + } + final String loanProductJSON = builder.build(null); + return this.loanTransactionHelper.getLoanProductId(loanProductJSON); + } + + private Integer applyForLoanApplication(final Integer clientID, final Integer loanProductID, String principal, String submitDate) { + LOG.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------"); + final String loanApplicationJSON = new LoanApplicationTestBuilder() // + .withPrincipal(principal) // + .withLoanTermFrequency("4") // + .withLoanTermFrequencyAsMonths() // + .withNumberOfRepayments("4") // + .withRepaymentEveryAfter("1") // + .withRepaymentFrequencyTypeAsMonths() // + .withInterestRatePerPeriod("2") // + .withAmortizationTypeAsEqualInstallments() // + .withInterestTypeAsDecliningBalance() // + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod() // + .withExpectedDisbursementDate(submitDate) // + .withSubmittedOnDate(submitDate) // + .build(clientID.toString(), loanProductID.toString(), null); + return this.loanTransactionHelper.getLoanId(loanApplicationJSON); + } + + private Integer fromStartToDisburseLoan(String submitApproveDisburseDate, String principal, final String accountingRule, + final Account... accounts) { + + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID); + + boolean allowMultipleDisbursals = false; + final Integer loanProductID = createLoanProduct(principal, allowMultipleDisbursals, accountingRule, accounts); + Assertions.assertNotNull(loanProductID); + + final Integer loanID = applyForLoanApplication(clientID, loanProductID, principal, submitApproveDisburseDate); + Assertions.assertNotNull(loanID); + HashMap loanStatusHashMap = LoanStatusChecker.getStatusOfLoan(this.requestSpec, this.responseSpec, loanID); + LoanStatusChecker.verifyLoanIsPending(loanStatusHashMap); + + LOG.info("-----------------------------------APPROVE LOAN-----------------------------------------"); + loanStatusHashMap = this.loanTransactionHelper.approveLoan(submitApproveDisburseDate, loanID); + LoanStatusChecker.verifyLoanIsApproved(loanStatusHashMap); + LoanStatusChecker.verifyLoanIsWaitingForDisbursal(loanStatusHashMap); + + LOG.info("-------------------------------DISBURSE LOAN -------------------------------------------"); // + // String loanDetails = this.loanTransactionHelper.getLoanDetails(this.requestSpec, this.responseSpec, loanID); + loanStatusHashMap = this.loanTransactionHelper.disburseLoan(submitApproveDisburseDate, loanID, principal); + LoanStatusChecker.verifyLoanIsActive(loanStatusHashMap); + return loanID; + } + + private HashMap makeRepayment(final String repaymentDate, final Float repayment) { + LOG.info("-------------Make repayment -----------"); + this.loanTransactionHelper.makeRepayment(repaymentDate, repayment, disbursedLoanID); + HashMap loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID, + "status"); + return loanStatusHashMap; + } + + @Test + public void creditBalanceRefundCanOnlyBeAppliedWhereLoanStatusIsOverpaidTest() { + HashMap loanStatusHashMap = makeRepayment("06 January 2022", 2000.00f); // not full payment + LoanStatusChecker.verifyLoanIsActive(loanStatusHashMap); + + final String creditBalanceRefundDate = "09 January 2022"; + final Float refund = 1000.00f; + final String externalId = null; + ArrayList cbrErrors = (ArrayList) loanTransactionHelperValidationError + .creditBalanceRefund(creditBalanceRefundDate, refund, externalId, disbursedLoanID, CommonConstants.RESPONSE_ERROR); + + assertEquals("error.msg.loan.credit.balance.refund.account.is.not.overpaid", + cbrErrors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + + // ArrayList loanSchedule = this.loanTransactionHelper.getLoanRepaymentSchedule(this.requestSpec, + // this.responseSpec, loanID); + // final int loanScheduleLineCount = loanSchedule.size(); + + } + + @Test + public void cantRefundMoreThanOverpaidTest() { + HashMap loanStatusHashMap = makeRepayment("06 January 2022", 20000.00f); // overpayment + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + final String creditBalanceRefundDate = "09 January 2022"; + Float refund = 10000.00f; + final String externalId = null; + ArrayList cbrErrors = (ArrayList) loanTransactionHelperValidationError + .creditBalanceRefund(creditBalanceRefundDate, refund, externalId, disbursedLoanID, CommonConstants.RESPONSE_ERROR); + + assertEquals("error.msg.transactionAmount.invalid.must.be.>zero.and<=overpaidamount", + cbrErrors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + + refund = (float) -1.00; + cbrErrors = (ArrayList) loanTransactionHelperValidationError.creditBalanceRefund(creditBalanceRefundDate, refund, + externalId, disbursedLoanID, CommonConstants.RESPONSE_ERROR); + assertEquals("validation.msg.loan.transaction.transactionAmount.not.greater.than.zero", + cbrErrors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + + } + + @Test + public void fullRefundChangesStatusToClosedObligationMetTest() { + HashMap loanStatusHashMap = makeRepayment("06 January 2022", 20000.00f); // overpayment + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + final Float totalOverpaid = (Float) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID, + "totalOverpaid"); + + final String creditBalanceRefundDate = "09 January 2022"; + final String externalId = null; + loanTransactionHelper.creditBalanceRefund(creditBalanceRefundDate, totalOverpaid, externalId, disbursedLoanID, null); + loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID, + "status"); + LoanStatusChecker.verifyLoanAccountIsClosed(loanStatusHashMap); + + final Float floatZero = 0.0f; + Float totalOverpaidAtEnd = (Float) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID, + "totalOverpaid"); + if (totalOverpaidAtEnd == null) { + totalOverpaidAtEnd = floatZero; + } + assertEquals(totalOverpaidAtEnd, floatZero); + + } + + @Test + public void partialRefundKeepsOverpaidStatusTest() { + HashMap loanStatusHashMap = makeRepayment("06 January 2022", 20000.00f); // overpayment + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + final Float refund = 5000.00f; // partial refund + + final String creditBalanceRefundDate = "09 January 2022"; + final String externalId = null; + loanTransactionHelper.creditBalanceRefund(creditBalanceRefundDate, refund, externalId, disbursedLoanID, null); + loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID, + "status"); + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + } + + @Test + public void newCreditBalanceRefundSavesExternalIdTest() { + + HashMap loanStatusHashMap = makeRepayment("06 January 2022", 20000.00f); // overpayment + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + final Float refund = 1000.00f; // partial refund + final String creditBalanceRefundDate = "09 January 2022"; + final String externalId = "cbrextID" + disbursedLoanID.toString(); + Integer resourceId = (Integer) loanTransactionHelper.creditBalanceRefund(creditBalanceRefundDate, refund, externalId, + disbursedLoanID, "resourceId"); + Assertions.assertNotNull(resourceId); + + HashMap creditBalanceRefundMap = this.loanTransactionHelper.getLoanTransactionDetails(disbursedLoanID, resourceId); + Assertions.assertNotNull(creditBalanceRefundMap.get("externalId")); + Assertions.assertEquals(creditBalanceRefundMap.get("externalId"), externalId, "Incorrect External Id Saved"); + + } + + @Test + public void newCreditBalanceRefundFindsDuplicateExternalIdTest() { + + HashMap loanStatusHashMap = makeRepayment("06 January 2022", 20000.00f); // overpayment + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + final Float refund = 1000.00f; // partial refund + final String creditBalanceRefundDate = "09 January 2022"; + final String externalId = "cbrextID" + disbursedLoanID.toString(); + final Integer resourceId = (Integer) loanTransactionHelper.creditBalanceRefund(creditBalanceRefundDate, refund, externalId, + disbursedLoanID, "resourceId"); + Assertions.assertNotNull(resourceId); + + final Float refund2 = 10.00f; // partial refund + final String creditBalanceRefundDate2 = "10 January 2022"; + ArrayList cbrErrors = (ArrayList) loanTransactionHelperValidationError + .creditBalanceRefund(creditBalanceRefundDate2, refund2, externalId, disbursedLoanID, CommonConstants.RESPONSE_ERROR); + assertEquals("error.msg.loan.creditBalanceRefund.duplicate.externalId", + cbrErrors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + + } + + @Test + public void newCreditBalanceRefundCreatesCorrectJournalEntriesForPeriodicAccrualsTest() { + + HashMap loanStatusHashMap = makeRepayment("06 January 2022", 20000.00f); // overpayment + LoanStatusChecker.verifyLoanAccountIsOverPaid(loanStatusHashMap); + + final Float refund = 1000.00f; // partial refund + final String creditBalanceRefundDate = "09 January 2022"; + final String externalId = null; + final Integer resourceId = (Integer) loanTransactionHelper.creditBalanceRefund(creditBalanceRefundDate, refund, externalId, + disbursedLoanID, "resourceId"); + Assertions.assertNotNull(resourceId); + + this.journalEntryHelper.checkJournalEntryForAssetAccount(assetAccount, creditBalanceRefundDate, + new JournalEntry(refund, JournalEntry.TransactionType.DEBIT)); + this.journalEntryHelper.checkJournalEntryForLiabilityAccount(overpaymentAccount, creditBalanceRefundDate, + new JournalEntry(refund, JournalEntry.TransactionType.CREDIT)); + + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationMultipleDisbursementsTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanMultipleDisbursementsIntegrationTest.java similarity index 99% rename from integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationMultipleDisbursementsTest.java rename to integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanMultipleDisbursementsIntegrationTest.java index 90283f695..aa40781a4 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationMultipleDisbursementsTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanMultipleDisbursementsIntegrationTest.java @@ -46,9 +46,9 @@ import org.slf4j.LoggerFactory; * repayments and verifying accounting transactions */ @SuppressWarnings({ "rawtypes", "unchecked" }) -public class ClientLoanIntegrationMultipleDisbursementsTest { +public class ClientLoanMultipleDisbursementsIntegrationTest { - private static final Logger LOG = LoggerFactory.getLogger(ClientLoanIntegrationMultipleDisbursementsTest.class); + private static final Logger LOG = LoggerFactory.getLogger(ClientLoanMultipleDisbursementsIntegrationTest.class); public static final String MINIMUM_OPENING_BALANCE = "1000.0"; public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java index f45ddc6d7..50942ac27 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java @@ -63,6 +63,7 @@ public class LoanTransactionHelper { private static final String WRITE_OFF_LOAN_COMMAND = "writeoff"; private static final String WAIVE_INTEREST_COMMAND = "waiveinterest"; private static final String MAKE_REPAYMENT_COMMAND = "repayment"; + private static final String CREDIT_BALANCE_REFUND_COMMAND = "creditBalanceRefund"; private static final String WITHDRAW_LOAN_APPLICATION_COMMAND = "withdrawnByApplicant"; private static final String RECOVER_FROM_GUARANTORS_COMMAND = "recoverGuarantees"; private static final String MAKE_REFUND_BY_CASH_COMMAND = "refundByCash"; @@ -332,6 +333,12 @@ public class LoanTransactionHelper { return resourceId; } + public Object creditBalanceRefund(final String date, final Float amountToBePaid, final String externalId, final Integer loanID, + String jsonAttributeToGetback) { + return performLoanTransaction(createLoanTransactionURL(CREDIT_BALANCE_REFUND_COMMAND, loanID), + getCreditBalanceRefundBodyAsJSON(date, amountToBePaid, externalId), jsonAttributeToGetback); + } + public HashMap makeRepayment(final String date, final Float amountToBePaid, final Integer loanID) { return (HashMap) performLoanTransaction(createLoanTransactionURL(MAKE_REPAYMENT_COMMAND, loanID), getRepaymentBodyAsJSON(date, amountToBePaid), ""); @@ -425,6 +432,12 @@ public class LoanTransactionHelper { return Utils.performServerGet(requestSpec, responseSpec, GET_LOAN_CHARGES_URL, ""); } + public HashMap getLoanTransactionDetails(final Integer loanId, final Integer txnId) { + final String GET_LOAN_CHARGES_URL = "/fineract-provider/api/v1/loans/" + loanId + "/transactions/" + txnId + "?" + + Utils.TENANT_IDENTIFIER; + return Utils.performServerGet(requestSpec, responseSpec, GET_LOAN_CHARGES_URL, ""); + } + public HashMap getPostDatedCheck(final Integer loanId, final Integer installmentId) { final String GET_POST_DATED_TRANS_URL = "/fineract-provider/api/v1/loans/" + loanId + "/postdatedchecks/" + installmentId + "?" + Utils.TENANT_IDENTIFIER; @@ -500,6 +513,19 @@ public class LoanTransactionHelper { return new Gson().toJson(map); } + private String getCreditBalanceRefundBodyAsJSON(final String transactionDate, final Float transactionAmount, final String externalId) { + final HashMap map = new HashMap<>(); + map.put("locale", "en"); + map.put("dateFormat", "dd MMMM yyyy"); + map.put("transactionDate", transactionDate); + map.put("transactionAmount", transactionAmount.toString()); + map.put("note", "Credit Balance Refund Made!!!"); + if (externalId != null) { + map.put("externalId", externalId); + } + return new Gson().toJson(map); + } + private String getRepaymentBodyAsJSON(final String transactionDate, final Float transactionAmount) { final HashMap map = new HashMap<>(); map.put("locale", "en");