Credit Balance Refund loan transaction

This commit is contained in:
John Woodlock
2022-04-19 10:04:16 +02:00
committed by Arnold Gálovics
parent 3d3e8c4083
commit d1168833cc
24 changed files with 671 additions and 21 deletions
@@ -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
@@ -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";
@@ -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"), //
@@ -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) {
@@ -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;
}
}
@@ -106,6 +106,8 @@ public class DefaultLoanLifecycleStateMachine implements LoanLifecycleStateMachi
break;
case WRITE_OFF_OUTSTANDING_UNDO:
break;
case LOAN_CREDIT_BALANCE_REFUND:
break;
default:
break;
}
@@ -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<Long> existingTransactionIds,
List<Long> 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<ApiParameterError> 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<Long> existingTransactionIds,
final List<Long> existingReversedTransactionIds, final boolean allowTransactionsOnHoliday, final List<Holiday> 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());
}
}
@@ -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);
}
@@ -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<Long> existingTransactionIds = new ArrayList<>();
final List<Long> 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 {
}
}
}
}
@@ -43,5 +43,6 @@ public enum LoanEvent {
LOAN_CLOSED, //
LOAN_EDIT_MULTI_DISBURSE_DATE, //
LOAN_REFUND, //
LOAN_FORECLOSURE;
LOAN_FORECLOSURE, //
LOAN_CREDIT_BALANCE_REFUND;
}
@@ -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;
}
@@ -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;
@@ -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);
}
}
@@ -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);
}
@@ -128,6 +128,8 @@ public interface LoanReadPlatformService {
LoanTransactionData retrieveRefundByCashTemplate(Long loanId);
LoanTransactionData retrieveCreditBalanceRefundTemplate(Long loanId);
Collection<InterestRatePeriodData> retrieveLoanInterestRatePeriodData(LoanAccountData loan);
Collection<Long> retrieveLoanIdsWithPendingIncomePostingTransactions();
@@ -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<PaymentTypeData> 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<PaymentTypeData> 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<PaymentTypeData> 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<PaymentTypeData> 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
@@ -116,4 +116,6 @@ public interface LoanWritePlatformService {
CommandProcessingResult makeGLIMLoanRepayment(Long loanId, JsonCommand command);
CommandProcessingResult creditBalanceRefund(Long loanId, JsonCommand command);
}
@@ -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<String, Object> 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
}
}
}
@@ -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;
}
@@ -30,4 +30,5 @@
<include file="parts/0008_loan_charge_add_external_id.xml" relativeToChangelogFile="true"/>
<include file="parts/0009_hold_reason_savings_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0010_lien_allowed_on_savings_account_products.xml" relativeToChangelogFile="true"/>
<include file="parts/0011_add_credit_balance_refund_permission.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1">
<insert tableName="m_permission">
<column name="grouping" value="transaction_loan" />
<column name="code" value="CREDITBALANCEREFUND_LOAN" />
<column name="entity_name" value="LOAN" />
<column name="action_name" value="CREDITBALANCEREFUND" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -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<HashMap> cbrErrors = (ArrayList<HashMap>) 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<HashMap> 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<HashMap> cbrErrors = (ArrayList<HashMap>) 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<HashMap>) 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<HashMap> cbrErrors = (ArrayList<HashMap>) 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));
}
}
@@ -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";
@@ -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<String, String> 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<String, String> map = new HashMap<>();
map.put("locale", "en");