FINERACT-1675 New Paid loanCharge Refund Transaction

This commit is contained in:
John Woodlock
2022-08-08 11:45:05 +02:00
committed by Aleksandar Vidakovic
parent 6f39c8ffed
commit 4034f4ae19
27 changed files with 1202 additions and 39 deletions
@@ -56,4 +56,6 @@ public class LoanTransactionDTO {
@Setter
private boolean isLoanToLoanTransfer;
private final String chargeRefundChargeType;
}
@@ -124,6 +124,7 @@ public class AccountingProcessorHelper {
final BigDecimal overPayments = (BigDecimal) map.get("overPaymentPortion");
final boolean reversed = (Boolean) map.get("reversed");
final Long paymentTypeId = (Long) map.get("paymentTypeId");
final String chargeRefundChargeType = (String) map.get("chargeRefundChargeType");
final List<ChargePaymentDTO> feePaymentDetails = new ArrayList<>();
final List<ChargePaymentDTO> penaltyPaymentDetails = new ArrayList<>();
@@ -151,7 +152,7 @@ public class AccountingProcessorHelper {
}
final LoanTransactionDTO transaction = new LoanTransactionDTO(transactionOfficeId, paymentTypeId, transactionId,
transactionDate, transactionType, amount, principal, interest, fees, penalties, overPayments, reversed,
penaltyPaymentDetails, feePaymentDetails, isAccountTransfer);
penaltyPaymentDetails, feePaymentDetails, isAccountTransfer, chargeRefundChargeType);
Boolean isLoanToLoanTransfer = (Boolean) accountingBridgeData.get("isLoanToLoanTransfer");
if (isLoanToLoanTransfer != null && isLoanToLoanTransfer) {
transaction.setLoanToLoanTransfer(true);
@@ -1301,4 +1302,27 @@ public class AccountingProcessorHelper {
private GLAccount getGLAccountById(final Long accountId) {
return this.accountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
}
public Integer getValueForFeeOrPenaltyIncomeAccount(final String chargeRefundChargeType) {
if (chargeRefundChargeType == null
|| !(chargeRefundChargeType.equalsIgnoreCase("P") || chargeRefundChargeType.equalsIgnoreCase("F"))) {
String errorValue;
if (chargeRefundChargeType == null) {
errorValue = "Null";
} else {
errorValue = chargeRefundChargeType;
}
throw new PlatformDataIntegrityException("error.msg.chargeRefundChargeType.can.only.be.P.or.F",
"chargeRefundChargeType can only be P (Penalty) or F(Fee) - Value is: " + errorValue);
}
Integer incomeAccount = null;
if (chargeRefundChargeType.equalsIgnoreCase("P")) {
incomeAccount = AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue();
} else {
incomeAccount = AccrualAccountsForLoan.INCOME_FROM_FEES.getValue();
}
return incomeAccount;
}
}
@@ -307,6 +307,19 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
}
}
}
/**
* Charge Refunds (and their reversals) have an extra refund related pair of journal entries in addition to
* those related to the repayment above
***/
if (!(totalDebitAmount.compareTo(BigDecimal.ZERO) == 0)) {
if (loanTransactionDTO.getTransactionType().isChargeRefund()) {
Integer incomeAccount = this.helper.getValueForFeeOrPenaltyIncomeAccount(loanTransactionDTO.getChargeRefundChargeType());
this.helper.createAccrualBasedJournalEntriesAndReversalsForLoan(office, currencyCode, incomeAccount,
AccrualAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, paymentTypeId, loanId, transactionId, transactionDate,
totalDebitAmount, isReversal);
}
}
}
/**
@@ -24,6 +24,7 @@ import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
@@ -256,6 +257,19 @@ public class CashBasedAccountingProcessorForLoan implements AccountingProcessorF
loanProductId, paymentTypeId, loanId, transactionId, transactionDate, totalDebitAmount, isReversal);
}
}
/**
* Charge Refunds (and their reversals) have an extra refund related pair of journal entries in addition to
* those related to the repayment above
***/
if (!(totalDebitAmount.compareTo(BigDecimal.ZERO) == 0)) {
if (loanTransactionDTO.getTransactionType().isChargeRefund()) {
Integer incomeAccount = this.helper.getValueForFeeOrPenaltyIncomeAccount(loanTransactionDTO.getChargeRefundChargeType());
this.helper.createAccrualBasedJournalEntriesAndReversalsForLoan(office, currencyCode, incomeAccount,
AccrualAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, paymentTypeId, loanId, transactionId, transactionDate,
totalDebitAmount, isReversal);
}
}
}
/**
@@ -824,6 +824,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder refundLoanCharge(final Long loanId) {
this.actionName = "CHARGEREFUND";
this.entityName = "LOAN";
this.entityId = null;
this.loanId = loanId;
this.href = "/loans/" + loanId + "/transactions/template?command=chargerefund";
return this;
}
public CommandWrapperBuilder loanRecoveryPaymentTransaction(final Long loanId) {
this.actionName = "RECOVERYPAYMENT";
this.entityName = "LOAN";
@@ -177,9 +177,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final Boolean isHolidayValidationDone = false;
final HolidayDetailDTO holidayDetailDto = null;
final boolean isRecoveryRepayment = false;
final String chargeRefundChargeType = null;
final LoanTransaction loanRepaymentTransaction = this.loanAccountDomainService.makeRepayment(LoanTransactionType.REPAYMENT,
toLoanAccount, new CommandProcessingResultBuilder(), transactionDate, transactionAmount, paymentDetail, null, null,
isRecoveryRepayment, isAccountTransfer, holidayDetailDto, isHolidayValidationDone);
isRecoveryRepayment, chargeRefundChargeType, isAccountTransfer, holidayDetailDto, isHolidayValidationDone);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleSavingsToLoanTransfer(command,
fromSavingsAccount, toLoanAccount, withdrawal, loanRepaymentTransaction);
@@ -340,10 +341,11 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final boolean isRecoveryRepayment = false;
final Boolean isHolidayValidationDone = false;
final HolidayDetailDTO holidayDetailDto = null;
final String chargeRefundChargeType = null;
loanTransaction = this.loanAccountDomainService.makeRepayment(LoanTransactionType.REPAYMENT, toLoanAccount,
new CommandProcessingResultBuilder(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), null, null, isRecoveryRepayment,
isAccountTransfer, holidayDetailDto, isHolidayValidationDone);
chargeRefundChargeType, isAccountTransfer, holidayDetailDto, isHolidayValidationDone);
}
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToLoanTransfer(accountTransferDTO, fromSavingsAccount,
@@ -475,10 +477,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
LoanTransaction disburseTransaction = this.loanAccountDomainService.makeDisburseTransaction(accountTransferDTO.getFromAccountId(),
accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
accountTransferDTO.getNoteText(), accountTransferDTO.getTxnExternalId(), true);
final String chargeRefundChargeType = null;
LoanTransaction repayTransaction = this.loanAccountDomainService.makeRepayment(LoanTransactionType.REPAYMENT, toLoanAccount,
new CommandProcessingResultBuilder(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), null, null, false, isAccountTransfer, null, false, true);
accountTransferDTO.getPaymentDetail(), null, null, false, chargeRefundChargeType, isAccountTransfer, null, false, true);
AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToLoanTransfer(accountTransferDTO,
fromLoanAccount, toLoanAccount, disburseTransaction, repayTransaction);
@@ -0,0 +1,28 @@
/**
* 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.businessevent.domain.loan.transaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public class LoanChargeRefundBusinessEvent extends LoanTransactionBusinessEvent {
public LoanChargeRefundBusinessEvent(LoanTransaction value) {
super(value);
}
}
@@ -120,7 +120,7 @@ public class LoanTransactionsApiResource {
+ "loans/1/transactions/template?command=recoverypayment" + "\n" + "loans/1/transactions/template?command=prepayLoan" + "\n"
+ "loans/1/transactions/template?command=refundbycash" + "\n" + "loans/1/transactions/template?command=refundbytransfer" + "\n"
+ "loans/1/transactions/template?command=foreclosure" + "\n"
+ "loans/1/transactions/template?command=creditBalanceRefund (returned 'amount' field will have the overpaid value")
+ "loans/1/transactions/template?command=creditBalanceRefund (returned 'amount' field will have the overpaid value)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.GetLoansLoanIdTransactionsTemplateResponse.class))) })
public String retrieveTransactionTemplate(@PathParam("loanId") @Parameter(description = "loanId") final Long loanId,
@@ -228,14 +228,14 @@ public class LoanTransactionsApiResource {
+ "Example Requests:\n" + "\n" + "loans/1/transactions?command=repayment" + " | Make a Repayment | \n"
+ "loans/1/transactions?command=merchantIssuedRefund" + " | Merchant Issued Refund | \n"
+ "loans/1/transactions?command=payoutRefund" + " | Payout Refund | \n" + "loans/1/transactions?command=goodwillCredit"
+ " | Goodwil Credit | \n" + "loans/1/transactions?command=waiveinterest" + " | Waive Interest | \n"
+ "loans/1/transactions?command=writeoff" + " | Write-off Loan | \n" + "loans/1/transactions?command=close-rescheduled"
+ " | Close Rescheduled Loan | \n" + "loans/1/transactions?command=close" + " | Close Loan | \n"
+ "loans/1/transactions?command=undowriteoff" + " | Undo Loan Write-off | \n" + "loans/1/transactions?command=recoverypayment"
+ " | Make Recovery Payment | \n" + "loans/1/transactions?command=refundByCash"
+ " | Make a Refund of an Active Loan by Cash | \n" + "loans/1/transactions?command=foreclosure"
+ " | Foreclosure of an Active Loan | \n" + "loans/1/transactions?command=creditBalanceRefund" + " | Credit Balance Refund"
+ " | \n")
+ " | Goodwil Credit | \n" + "loans/1/transactions?command=chargeRefund" + " | Charge Refund | \n"
+ "loans/1/transactions?command=waiveinterest" + " | Waive Interest | \n" + "loans/1/transactions?command=writeoff"
+ " | Write-off Loan | \n" + "loans/1/transactions?command=close-rescheduled" + " | Close Rescheduled Loan | \n"
+ "loans/1/transactions?command=close" + " | Close Loan | \n" + "loans/1/transactions?command=undowriteoff"
+ " | Undo Loan Write-off | \n" + "loans/1/transactions?command=recoverypayment" + " | Make Recovery Payment | \n"
+ "loans/1/transactions?command=refundByCash" + " | Make a Refund of an Active Loan by Cash | \n"
+ "loans/1/transactions?command=foreclosure" + " | Foreclosure of an Active Loan | \n"
+ "loans/1/transactions?command=creditBalanceRefund" + " | Credit Balance Refund" + " | \n")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.PostLoansLoanIdTransactionsRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.PostLoansLoanIdTransactionsResponse.class))) })
@@ -258,6 +258,9 @@ public class LoanTransactionsApiResource {
} else if (is(commandParam, "goodwillCredit")) {
final CommandWrapper commandRequest = builder.loanGoodwillCreditTransaction(loanId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "chargeRefund")) {
final CommandWrapper commandRequest = builder.refundLoanCharge(loanId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "waiveinterest")) {
final CommandWrapper commandRequest = builder.waiveInterestPortionTransaction(loanId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
@@ -34,6 +34,7 @@ public class LoanTransactionEnumData {
private final boolean merchantIssuedRefund;
private final boolean payoutRefund;
private final boolean goodwillCredit;
private final boolean chargeRefund;
private final boolean contra;
private final boolean waiveInterest;
private final boolean waiveCharges;
@@ -59,6 +60,7 @@ public class LoanTransactionEnumData {
this.merchantIssuedRefund = Long.valueOf(21).equals(this.id);
this.payoutRefund = Long.valueOf(22).equals(this.id);
this.goodwillCredit = Long.valueOf(23).equals(this.id);
this.chargeRefund = Long.valueOf(24).equals(this.id);
this.contra = Long.valueOf(3).equals(this.id);
this.waiveInterest = Long.valueOf(4).equals(this.id);
this.waiveCharges = Long.valueOf(9).equals(this.id);
@@ -101,7 +103,7 @@ public class LoanTransactionEnumData {
}
public boolean isRepaymentType() {
if (isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit()) {
if (isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund()) {
return true;
}
return false;
@@ -131,6 +133,10 @@ public class LoanTransactionEnumData {
return this.goodwillCredit;
}
public boolean isChargeRefund() {
return this.chargeRefund;
}
public boolean isWaiveInterest() {
return this.waiveInterest;
}
@@ -113,6 +113,7 @@ import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTrans
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidRefundDateException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanApplicationDateException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanChargeRefundException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanDisbursalException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanForeclosureException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanOfficerAssignmentDateException;
@@ -3055,6 +3056,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
}
validateRepaymentTypeAccountStatus(repaymentTransaction, event);
validateActivityNotBeforeClientOrGroupTransferDate(event, repaymentTransaction.getTransactionDate());
validateRepaymentTypeTransactionNotBeforeAChargeRefund(repaymentTransaction, "created");
validateActivityNotBeforeLastTransactionDate(event, repaymentTransaction.getTransactionDate());
if (!isHolidayValidationDone) {
validateRepaymentDateIsOnHoliday(repaymentTransaction.getTransactionDate(), holidayDetailDTO.isAllowTransactionsOnHoliday(),
@@ -3073,7 +3075,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
private void validateRepaymentTypeAccountStatus(LoanTransaction repaymentTransaction, LoanEvent event) {
if (repaymentTransaction.isGoodwillCredit() || repaymentTransaction.isMerchantIssuedRefund()
|| repaymentTransaction.isPayoutRefund()) {
|| repaymentTransaction.isPayoutRefund() || repaymentTransaction.isChargeRefund()) {
if (!(isOpen() || isClosedObligationsMet() || isOverPaid())) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
@@ -4917,6 +4919,20 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
}
}
public void validateRepaymentTypeTransactionNotBeforeAChargeRefund(final LoanTransaction repaymentTransaction,
final String reversedOrCreated) {
if (repaymentTransaction.isRepaymentType() && !repaymentTransaction.isChargeRefund()) {
for (LoanTransaction txn : this.getLoanTransactions()) {
if (txn.isChargeRefund() && repaymentTransaction.getTransactionDate().isBefore(txn.getTransactionDate())) {
final String errorMessage = "loan.transaction.cant.be." + reversedOrCreated + ".because.later.charge.refund.exists";
final String details = "Loan Transaction: " + this.getId() + " Can't be " + reversedOrCreated
+ " because a Later Charge Refund Exists.";
throw new LoanChargeRefundException(errorMessage, details);
}
}
}
}
public LocalDate getLastUserTransactionDate() {
LocalDate currentTransactionDate = getDisbursementDate();
for (final LoanTransaction previousTransaction : this.loanTransactions) {
@@ -30,7 +30,8 @@ public interface LoanAccountDomainService {
LoanTransaction makeRepayment(LoanTransactionType repaymentTransactionType, Loan loan, CommandProcessingResultBuilder builderResult,
LocalDate transactionDate, BigDecimal transactionAmount, PaymentDetail paymentDetail, String noteText, String txnExternalId,
boolean isRecoveryRepayment, boolean isAccountTransfer, HolidayDetailDTO holidatDetailDto, Boolean isHolidayValidationDone);
boolean isRecoveryRepayment, String chargeRefundChargeType, boolean isAccountTransfer, HolidayDetailDTO holidatDetailDto,
Boolean isHolidayValidationDone);
LoanTransaction makeRefund(Long accountId, CommandProcessingResultBuilder builderResult, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, String noteText, String txnExternalId);
@@ -63,8 +64,8 @@ public interface LoanAccountDomainService {
LoanTransaction makeRepayment(LoanTransactionType repaymentTransactionType, Loan loan, CommandProcessingResultBuilder builderResult,
LocalDate transactionDate, BigDecimal transactionAmount, PaymentDetail paymentDetail, String noteText, String txnExternalId,
boolean isRecoveryRepayment, boolean isAccountTransfer, HolidayDetailDTO holidayDetailDto, Boolean isHolidayValidationDone,
boolean isLoanToLoanTransfer);
boolean isRecoveryRepayment, String chargeRefundChargeType, boolean isAccountTransfer, HolidayDetailDTO holidayDetailDto,
Boolean isHolidayValidationDone, boolean isLoanToLoanTransfer);
void saveLoanWithDataIntegrityViolationChecks(Loan loan);
@@ -129,9 +129,11 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
public LoanTransaction makeRepayment(final LoanTransactionType repaymentTransactionType, final Loan loan,
final CommandProcessingResultBuilder builderResult, final LocalDate transactionDate, final BigDecimal transactionAmount,
final PaymentDetail paymentDetail, final String noteText, final String txnExternalId, final boolean isRecoveryRepayment,
boolean isAccountTransfer, HolidayDetailDTO holidayDetailDto, Boolean isHolidayValidationDone) {
final String chargeRefundChargeType, boolean isAccountTransfer, HolidayDetailDTO holidayDetailDto,
Boolean isHolidayValidationDone) {
return makeRepayment(repaymentTransactionType, loan, builderResult, transactionDate, transactionAmount, paymentDetail, noteText,
txnExternalId, isRecoveryRepayment, isAccountTransfer, holidayDetailDto, isHolidayValidationDone, false);
txnExternalId, isRecoveryRepayment, chargeRefundChargeType, isAccountTransfer, holidayDetailDto, isHolidayValidationDone,
false);
}
@Transactional
@@ -154,8 +156,8 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
public LoanTransaction makeRepayment(final LoanTransactionType repaymentTransactionType, final Loan loan,
final CommandProcessingResultBuilder builderResult, final LocalDate transactionDate, final BigDecimal transactionAmount,
final PaymentDetail paymentDetail, final String noteText, final String txnExternalId, final boolean isRecoveryRepayment,
boolean isAccountTransfer, HolidayDetailDTO holidayDetailDto, Boolean isHolidayValidationDone,
final boolean isLoanToLoanTransfer) {
final String chargeRefundChargeType, boolean isAccountTransfer, HolidayDetailDTO holidayDetailDto,
Boolean isHolidayValidationDone, final boolean isLoanToLoanTransfer) {
AppUser currentUser = getAppUserIfPresent();
checkClientOrGroupActive(loan);
@@ -181,7 +183,7 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
txnExternalId);
} else {
newRepaymentTransaction = LoanTransaction.repaymentType(repaymentTransactionType, loan.getOffice(), repaymentAmount,
paymentDetail, transactionDate, txnExternalId);
paymentDetail, transactionDate, txnExternalId, chargeRefundChargeType);
}
LocalDate recalculateFrom = null;
@@ -223,9 +225,11 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
recalculateAccruals(loan);
LoanTransactionBusinessEvent transactionRepaymentEvent = getTransactionRepaymentTypeBusinessEvent(repaymentTransactionType,
isRecoveryRepayment, newRepaymentTransaction);
businessEventNotifierService.notifyPostBusinessEvent(transactionRepaymentEvent);
if (!repaymentTransactionType.isChargeRefund()) {
LoanTransactionBusinessEvent transactionRepaymentEvent = getTransactionRepaymentTypeBusinessEvent(repaymentTransactionType,
isRecoveryRepayment, newRepaymentTransaction);
businessEventNotifierService.notifyPostBusinessEvent(transactionRepaymentEvent);
}
// disable all active standing orders linked to this loan if status
// changes to closed
@@ -277,6 +281,8 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
repaymentEvent = new LoanTransactionPayoutRefundPreBusinessEvent(loan);
} else if (repaymentTransactionType.isGoodwillCredit()) {
repaymentEvent = new LoanTransactionGoodwillCreditPreBusinessEvent(loan);
} else if (repaymentTransactionType.isChargeRefund()) {
repaymentEvent = new LoanChargePaymentPreBusinessEvent(loan);
} else if (isRecoveryRepayment) {
repaymentEvent = new LoanTransactionRecoveryPaymentPreBusinessEvent(loan);
}
@@ -294,6 +300,8 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
repaymentEvent = new LoanTransactionPayoutRefundPostBusinessEvent(transaction);
} else if (repaymentTransactionType.isGoodwillCredit()) {
repaymentEvent = new LoanTransactionGoodwillCreditPostBusinessEvent(transaction);
} else if (repaymentTransactionType.isChargeRefund()) {
repaymentEvent = new LoanChargePaymentPostBusinessEvent(transaction);
} else if (isRecoveryRepayment) {
repaymentEvent = new LoanTransactionRecoveryPaymentPostBusinessEvent(transaction);
}
@@ -115,6 +115,9 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
@Column(name = "manually_adjusted_or_reversed", nullable = false)
private boolean manuallyAdjustedOrReversed;
@Column(name = "charge_refund_charge_type", length = 1, nullable = true, unique = true)
private String chargeRefundChargeType;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY, mappedBy = "loanTransaction")
private Set<LoanCollateralManagement> loanCollateralManagementSet = new HashSet<>();
@@ -147,8 +150,9 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
}
public static LoanTransaction repaymentType(final LoanTransactionType repaymentType, final Office office, final Money amount,
final PaymentDetail paymentDetail, final LocalDate paymentDate, final String externalId) {
return new LoanTransaction(null, office, repaymentType, paymentDetail, amount.getAmount(), paymentDate, externalId);
final PaymentDetail paymentDetail, final LocalDate paymentDate, final String externalId, final String chargeRefundChargeType) {
return new LoanTransaction(null, office, repaymentType, paymentDetail, amount.getAmount(), paymentDate, externalId,
chargeRefundChargeType);
}
public void setLoanTransactionToRepaymentScheduleMappings(final Integer installmentId, final BigDecimal chargePerInstallment) {
@@ -344,7 +348,21 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
this.submittedOnDate = DateUtils.getBusinessLocalDate();
}
private LoanTransaction(final Loan loan, final Office office, final LoanTransactionType type, final PaymentDetail paymentDetail,
final BigDecimal amount, final LocalDate date, final String externalId, final String chargeRefundChargeType) {
this.loan = loan;
this.typeOf = type.getValue();
this.paymentDetail = paymentDetail;
this.amount = amount;
this.dateOf = date;
this.externalId = externalId;
this.office = office;
this.submittedOnDate = DateUtils.getBusinessLocalDate();
this.chargeRefundChargeType = chargeRefundChargeType;
}
public void reverse() {
this.loan.validateRepaymentTypeTransactionNotBeforeAChargeRefund(this, "reversed");
this.reversed = true;
this.loanTransactionToRepaymentScheduleMappings.clear();
}
@@ -480,7 +498,7 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
}
public boolean isRepaymentType() {
return isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit();
return isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund();
}
public boolean isRepayment() {
@@ -499,6 +517,10 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
return LoanTransactionType.GOODWILL_CREDIT.equals(getTypeOf()) && isNotReversed();
}
public boolean isChargeRefund() {
return LoanTransactionType.CHARGE_REFUND.equals(getTypeOf()) && isNotReversed();
}
public boolean isNotRepaymentType() {
return !isRepaymentType();
}
@@ -640,6 +662,9 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
thisTransactionData.put("feeChargesPortion", this.feeChargesPortion);
thisTransactionData.put("penaltyChargesPortion", this.penaltyChargesPortion);
thisTransactionData.put("overPaymentPortion", this.overPaymentPortion);
if (transactionType.isChargeRefund()) {
thisTransactionData.put("chargeRefundChargeType", this.chargeRefundChargeType);
}
if (this.paymentDetail != null) {
thisTransactionData.put("paymentTypeId", this.paymentDetail.getPaymentType().getId());
@@ -55,7 +55,8 @@ public enum LoanTransactionType {
CREDIT_BALANCE_REFUND(20, "loanTransactionType.creditBalanceRefund"), //
MERCHANT_ISSUED_REFUND(21, "loanTransactionType.merchantIssuedRefund"), //
PAYOUT_REFUND(22, "loanTransactionType.payoutRefund"), //
GOODWILL_CREDIT(23, "loanTransactionType.goodwillCredit");
GOODWILL_CREDIT(23, "loanTransactionType.goodwillCredit"), //
CHARGE_REFUND(24, "loanTransactionType.chargeRefund");
private final Integer value;
private final String code;
@@ -147,6 +148,9 @@ public enum LoanTransactionType {
case 23:
loanTransactionType = LoanTransactionType.GOODWILL_CREDIT;
break;
case 24:
loanTransactionType = LoanTransactionType.CHARGE_REFUND;
break;
default:
loanTransactionType = LoanTransactionType.INVALID;
break;
@@ -178,8 +182,12 @@ public enum LoanTransactionType {
return this.value.equals(LoanTransactionType.GOODWILL_CREDIT.getValue());
}
public boolean isChargeRefund() {
return this.value.equals(LoanTransactionType.CHARGE_REFUND.getValue());
}
public boolean isRepaymentType() {
return (isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit());
return (isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund());
}
public boolean isRecoveryRepayment() {
@@ -0,0 +1,29 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class LoanChargeRefundException extends AbstractPlatformDomainRuleException {
public LoanChargeRefundException(final String defaultUserMessage, final Object... defaultUserMessageArgs) {
super("error.msg." + defaultUserMessage, defaultUserMessage, defaultUserMessageArgs);
}
}
@@ -0,0 +1,53 @@
/**
* 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 org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.DataIntegrityErrorHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
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;
@Service
@RequiredArgsConstructor
@CommandType(entity = "LOAN", action = "CHARGEREFUND")
public class LoanChargeRefundCommandHandler implements NewCommandSourceHandler {
private final LoanWritePlatformService writePlatformService;
private final DataIntegrityErrorHandler dataIntegrityErrorHandler;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
try {
return this.writePlatformService.loanChargeRefund(command.getLoanId(), command);
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
dataIntegrityErrorHandler.handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve, "loancharge.refund",
"Loancharge Refund");
return CommandProcessingResult.empty();
}
}
}
@@ -444,6 +444,41 @@ public final class LoanEventApiJsonValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateLoanChargeRefundTransaction(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final String loanChargeIdParam = "loanChargeId";
final String installmentNumberParam = "installmentNumber";
final String dueDateParam = "dueDate";
Set<String> transactionParameters = new HashSet<>(
Arrays.asList(loanChargeIdParam, dueDateParam, "locale", "dateFormat", installmentNumberParam, //
// remainder below relate to payment part of refund and not validated here
"transactionAmount", "externalId", "note", "locale", "dateFormat", "paymentTypeId", "accountNumber", "checkNumber",
"routingCode", "receiptNumber", "bankNumber"));
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, transactionParameters);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource("loan.charge.refund.transaction");
JsonElement element = this.fromApiJsonHelper.parse(json);
final Long chargeId = this.fromApiJsonHelper.extractLongNamed(loanChargeIdParam, element);
baseDataValidator.reset().parameter(loanChargeIdParam).value(chargeId).notNull().integerGreaterThanZero();
final Integer installmentNumber = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(installmentNumberParam, element);
baseDataValidator.reset().parameter(installmentNumberParam).value(installmentNumber).ignoreIfNull().integerGreaterThanZero();
final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element);
baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).ignoreIfNull().positiveAmount();
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateInstallmentChargeTransaction(final String json) {
if (StringUtils.isBlank(json)) {
@@ -1669,7 +1669,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService {
LocalDate organisationStartDate = this.configurationDomainService.retrieveOrganisationStartDate();
final StringBuilder sqlBuilder = new StringBuilder(400);
sqlBuilder.append("select ").append(mapper.schema()).append(
" where (recaldet.is_compounding_to_be_posted_as_transaction is null or recaldet.is_compounding_to_be_posted_as_transaction = false) ")
" where (recaldet.is_compounding_to_be_posted_as_transaction is null or recaldet.is_compounding_to_be_posted_as_transaction = false) ")
.append(" and (((ls.fee_charges_amount <> COALESCE(ls.accrual_fee_charges_derived, 0))")
.append(" or (ls.penalty_charges_amount <> COALESCE(ls.accrual_penalty_charges_derived, 0))")
.append(" or (ls.interest_amount <> COALESCE(ls.accrual_interest_derived, 0)))")
@@ -68,6 +68,8 @@ public interface LoanWritePlatformService {
CommandProcessingResult undoWaiveLoanCharge(JsonCommand command);
CommandProcessingResult loanChargeRefund(Long loanId, JsonCommand command);
CommandProcessingResult loanReassignment(Long loanId, JsonCommand command);
CommandProcessingResult bulkLoanReassignment(JsonCommand command);
@@ -105,6 +105,7 @@ import org.apache.fineract.portfolio.businessevent.domain.loan.charge.LoanDelete
import org.apache.fineract.portfolio.businessevent.domain.loan.charge.LoanUpdateChargeBusinessEvent;
import org.apache.fineract.portfolio.businessevent.domain.loan.charge.LoanWaiveChargeBusinessEvent;
import org.apache.fineract.portfolio.businessevent.domain.loan.charge.LoanWaiveChargeUndoBusinessEvent;
import org.apache.fineract.portfolio.businessevent.domain.loan.transaction.LoanChargePaymentPostBusinessEvent;
import org.apache.fineract.portfolio.businessevent.domain.loan.transaction.LoanUndoWrittenOffBusinessEvent;
import org.apache.fineract.portfolio.businessevent.domain.loan.transaction.LoanWaiveInterestBusinessEvent;
import org.apache.fineract.portfolio.businessevent.domain.loan.transaction.LoanWrittenOffPostBusinessEvent;
@@ -180,6 +181,7 @@ import org.apache.fineract.portfolio.loanaccount.exception.ExceedingTrancheCount
import org.apache.fineract.portfolio.loanaccount.exception.InstallmentNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidPaidInAdvanceAmountException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanChargeRefundException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanForeclosureException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanMultiDisbursementException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanOfficerAssignmentException;
@@ -888,6 +890,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
@Override
public CommandProcessingResult makeLoanRepayment(final LoanTransactionType repaymentTransactionType, final Long loanId,
final JsonCommand command, final boolean isRecoveryRepayment) {
final String chargeRefundChargeType = null;
return makeLoanRepaymentWithChargeRefundChargeType(repaymentTransactionType, loanId, command, isRecoveryRepayment,
chargeRefundChargeType);
}
private CommandProcessingResult makeLoanRepaymentWithChargeRefundChargeType(final LoanTransactionType repaymentTransactionType,
final Long loanId, final JsonCommand command, final boolean isRecoveryRepayment, final String chargeRefundChargeType) {
this.loanUtilService.validateRepaymentTransactionType(repaymentTransactionType);
this.loanEventApiJsonValidator.validateNewRepaymentTransaction(command.json());
@@ -915,7 +924,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final CommandProcessingResultBuilder commandProcessingResultBuilder = new CommandProcessingResultBuilder();
LoanTransaction loanTransaction = this.loanAccountDomainService.makeRepayment(repaymentTransactionType, loan,
commandProcessingResultBuilder, transactionDate, transactionAmount, paymentDetail, noteText, txnExternalId,
isRecoveryRepayment, isAccountTransfer, holidayDetailDto, isHolidayValidationDone);
isRecoveryRepayment, chargeRefundChargeType, isAccountTransfer, holidayDetailDto, isHolidayValidationDone);
// Update loan transaction on repayment.
if (AccountType.fromInt(loan.getLoanType()).isIndividualAccount()) {
@@ -984,10 +993,11 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
this.paymentDetailWritePlatformService.persistPaymentDetail(paymentDetail);
}
final CommandProcessingResultBuilder commandProcessingResultBuilder = new CommandProcessingResultBuilder();
final String chargeRefundChargeType = null;
LoanTransaction loanTransaction = this.loanAccountDomainService.makeRepayment(LoanTransactionType.REPAYMENT, loan,
commandProcessingResultBuilder, bulkRepaymentCommand.getTransactionDate(),
singleLoanRepaymentCommand.getTransactionAmount(), paymentDetail, bulkRepaymentCommand.getNote(), null,
isRecoveryRepayment, isAccountTransfer, holidayDetailDTO, isHolidayValidationDone);
isRecoveryRepayment, chargeRefundChargeType, isAccountTransfer, holidayDetailDTO, isHolidayValidationDone);
transactionIds.add(loanTransaction.getId());
}
}
@@ -1646,6 +1656,189 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
.build();
}
@Transactional
@Override
public CommandProcessingResult loanChargeRefund(final Long loanId, final JsonCommand command) {
this.loanEventApiJsonValidator.validateLoanChargeRefundTransaction(command.json());
final Long loanChargeId = command.longValueOfParameterNamed("loanChargeId");
final LoanCharge loanCharge = retrieveLoanChargeBy(loanId, loanChargeId);
final Integer installmentNumber = command.integerValueOfParameterNamed("installmentNumber");
final LocalDate dueDate = command.localDateValueOfParameterNamed("dueDate");
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
final LoanInstallmentCharge installmentChargeEntry = loanChargeRefundEntranceValidation(loanCharge, installmentNumber, dueDate);
Integer installmentNumberIdentified = null;
if (installmentChargeEntry != null) {
installmentNumberIdentified = installmentChargeEntry.getRepaymentInstallment().getInstallmentNumber();
}
final BigDecimal fullRefundAbleAmount = loanChargeValidateRefundAmount(loanCharge, installmentChargeEntry, transactionAmount);
JsonCommand repaymentJsonCommand = adaptLoanChargeRefundCommandForFutherRepaymentProcessing(command, fullRefundAbleAmount);
boolean isRecoveryRepayment = false;
String chargeRefundChargeType = "F";
if (loanCharge.isPenaltyCharge()) {
chargeRefundChargeType = "P";
}
// chargeRefundChargeType only included as a parameter for accounting reason - in order to identify whether fee
// or penalty GL account is relevant
CommandProcessingResult result = makeLoanRepaymentWithChargeRefundChargeType(LoanTransactionType.CHARGE_REFUND,
repaymentJsonCommand.getLoanId(), repaymentJsonCommand, isRecoveryRepayment, chargeRefundChargeType);
Long loanChargeRefundTransactionId = result.resourceId();
LoanTransaction newChargeRefundTxn = null;
for (LoanTransaction chargeRefundTxn : loanCharge.getLoan().getLoanTransactions()) {
if (chargeRefundTxn.getId().equals(loanChargeRefundTransactionId)) {
newChargeRefundTxn = chargeRefundTxn;
final BigDecimal appliedRefundAmount = newChargeRefundTxn.getAmount(loanCharge.getLoan().getCurrency()).getAmount()
.multiply(BigDecimal.valueOf(-1));
final LoanChargePaidBy loanChargePaidByForChargeRefund = new LoanChargePaidBy(newChargeRefundTxn, loanCharge,
appliedRefundAmount, installmentNumberIdentified);
newChargeRefundTxn.getLoanChargesPaid().add(loanChargePaidByForChargeRefund);
loanCharge.getLoanChargePaidBySet().add(loanChargePaidByForChargeRefund);
break;
}
}
businessEventNotifierService.notifyPostBusinessEvent(new LoanChargePaymentPostBusinessEvent(newChargeRefundTxn));
return result;
}
private JsonCommand adaptLoanChargeRefundCommandForFutherRepaymentProcessing(JsonCommand command, BigDecimal fullRefundAbleAmount) {
// creates JsonCommand for onward repayment processing
JsonObject jsonObject = (JsonObject) this.fromApiJsonHelper.parse(command.json());
String dateFormat;
if (this.fromApiJsonHelper.parameterExists("dateFormat", jsonObject)) {
dateFormat = this.fromApiJsonHelper.extractStringNamed("dateFormat", jsonObject);
} else {
dateFormat = "dd MMMM yyyy";
jsonObject.addProperty("dateFormat", dateFormat);
}
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(dateFormat);
LocalDate transactionDate = DateUtils.getLocalDateOfTenant();
String transactionDateString = transactionDate.format(dateTimeFormatter);
jsonObject.addProperty("transactionDate", transactionDateString);
if (!this.fromApiJsonHelper.parameterExists("transactionAmount", jsonObject)) {
jsonObject.addProperty("transactionAmount", fullRefundAbleAmount.toString());
}
jsonObject.remove("loanChargeId");
jsonObject.remove("installmentNumber");
jsonObject.remove("dueDate");
JsonCommand repaymentJsonCommand = JsonCommand.fromExistingCommand(command, jsonObject);
return repaymentJsonCommand;
}
private BigDecimal loanChargeValidateRefundAmount(LoanCharge loanCharge, LoanInstallmentCharge installmentChargeEntry,
BigDecimal transactionAmount) {
// if transactionAmount not provided return max refundable amount (amount paid minus previous refunds)
BigDecimal chargeAmountPaid = BigDecimal.ZERO;
BigDecimal chargeAmountRefunded = BigDecimal.ZERO;
MonetaryCurrency loanCurrency = loanCharge.getLoan().getCurrency();
if (loanCharge.isInstalmentFee()) {
final Integer installmentNumber = installmentChargeEntry.getRepaymentInstallment().getInstallmentNumber();
chargeAmountPaid = installmentChargeEntry.getAmountPaid(loanCurrency).getAmount();
for (LoanChargePaidBy loanChargePaidBy : loanCharge.getLoanChargePaidBySet()) {
if (installmentNumber.equals(loanChargePaidBy.getInstallmentNumber())) {
if (isRefundElementOfChargeRefund(loanChargePaidBy)) {
chargeAmountRefunded = chargeAmountRefunded.add(loanChargePaidBy.getAmount());
}
}
}
} else {
chargeAmountPaid = loanCharge.getAmountPaid(loanCurrency).getAmount();
for (LoanChargePaidBy loanChargePaidBy : loanCharge.getLoanChargePaidBySet()) {
if (isRefundElementOfChargeRefund(loanChargePaidBy)) {
chargeAmountRefunded = chargeAmountRefunded.add(loanChargePaidBy.getAmount());
}
}
}
chargeAmountRefunded = chargeAmountRefunded.multiply(BigDecimal.valueOf(-1));
if (chargeAmountRefunded.compareTo(chargeAmountPaid) > 0) {
final String errorMessage = "loan.charge.more.refunded.than.paid.unexpected.system.error";
final String details = "Paid: " + chargeAmountPaid.toString() + " Refunded: " + chargeAmountPaid.toString();
throw new LoanChargeRefundException(errorMessage, details);
}
BigDecimal refundableAmount = chargeAmountPaid.subtract(chargeAmountRefunded);
if (transactionAmount != null) { // refund amount was provided.
if (transactionAmount.compareTo(refundableAmount) > 0) {
final String errorMessage = "loan.charge.transaction.amount.is.more.than.is.refundable";
final String details = "transactionAmount: " + transactionAmount.toString() + " Refundable: "
+ refundableAmount.toString();
throw new LoanChargeRefundException(errorMessage, details);
}
}
return refundableAmount;
}
private boolean isRefundElementOfChargeRefund(LoanChargePaidBy loanChargePaidBy) {
// The Refund Element is always negative
return (loanChargePaidBy.getLoanTransaction().isChargeRefund() && loanChargePaidBy.getAmount().compareTo(BigDecimal.ZERO) < 0);
}
private LoanInstallmentCharge loanChargeRefundEntranceValidation(LoanCharge loanCharge, Integer installmentNumber, LocalDate dueDate) {
LoanInstallmentCharge installmentChargeEntry = null;
Loan loan = loanCharge.getLoan();
if (!(loan.isOpen() || loan.status().isClosedObligationsMet() || loan.status().isOverpaid())) {
final String errorMessage = "loan.charge.refund.invalid.status";
throw new LoanChargeRefundException(errorMessage, loan.status().toString());
}
if (dueDate != null && installmentNumber != null) {
throwLoanChargeRefundException("loan.charge.refund.dueDate.and.installmentNumber.provided.use.only.one", installmentNumber,
dueDate);
}
if (loanCharge.isInstalmentFee()) { // identify specific installment
if (dueDate == null && installmentNumber == null) {
throwLoanChargeRefundException(
"loan.charge.refund.neither.dueDate.nor.installmentNumber.provided.for.this.installment.charge", installmentNumber,
dueDate);
}
if (dueDate != null) {
installmentChargeEntry = loanCharge.getInstallmentLoanCharge(dueDate);
} else if (installmentNumber != null) {
installmentChargeEntry = loanCharge.getInstallmentLoanCharge(installmentNumber);
}
if (installmentChargeEntry == null) {
throwLoanChargeRefundException("loan.charge.refund.installment.not.found", installmentNumber, dueDate);
}
} else {
if (dueDate != null || installmentNumber != null) {
throwLoanChargeRefundException(
"loan.charge.refund.dueDate.or.installmentNumber.provided.but.this.is.not.an.installment.charge", installmentNumber,
dueDate);
}
}
return installmentChargeEntry;
}
private void throwLoanChargeRefundException(String errorMessage, Integer installmentNumber, LocalDate dueDate) {
String dueDateValue = "";
String installmentNumberValue = "";
if (dueDate != null) {
dueDateValue = dueDate.toString();
}
if (installmentNumber != null) {
installmentNumberValue = installmentNumber.toString();
}
throw new LoanChargeRefundException(errorMessage, "dueDate: " + dueDateValue + " installmentNumber: " + installmentNumberValue);
}
@Transactional
@Override
public CommandProcessingResult undoWaiveLoanCharge(final JsonCommand command) {
@@ -427,6 +427,10 @@ public final class LoanEnumerations {
optionData = new LoanTransactionEnumData(LoanTransactionType.GOODWILL_CREDIT.getValue().longValue(),
LoanTransactionType.GOODWILL_CREDIT.getCode(), "Goodwill Credit");
break;
case CHARGE_REFUND:
optionData = new LoanTransactionEnumData(LoanTransactionType.CHARGE_REFUND.getValue().longValue(),
LoanTransactionType.CHARGE_REFUND.getCode(), "Charge Refund");
break;
}
return optionData;
}
@@ -46,4 +46,6 @@
<include file="parts/0024_add_audit_entries.xml" relativeToChangelogFile="true"/>
<include file="parts/0025_add_audit_entries_to_journal_entry.xml" relativeToChangelogFile="true"/>
<include file="parts/0026_reversals_for_reversed_transactions.xml" relativeToChangelogFile="true"/>
<include file="parts/0027_add_charge_refund_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0028_add_charge_refund_charge_type_to_loan_transaction.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="CHARGEREFUND_LOAN" />
<column name="entity_name" value="LOAN" />
<column name="action_name" value="CHARGEREFUND" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?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">
<addColumn tableName="m_loan_transaction">
<column name="charge_refund_charge_type" type="VARCHAR(1)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,602 @@
/**
* 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 static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.Gson;
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.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
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.charges.ChargesHelper;
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 ClientLoanChargeRefundIntegrationTest {
private static final Logger LOG = LoggerFactory.getLogger(ClientLoanChargeRefundIntegrationTest.class);
private Integer disbursedLoanID;
private Account assetAccount;
private Account feeIncomeAccount;
private Account penaltyIncomeAccount;
private Account expenseAccount;
private Account overpaymentAccount;
private static final String ZERO_INTEREST_RATE = "0";
private static final String FOUR_INSTALLMENTS = "4";
private static final String NONE = "1";
private static final String CASH_BASED = "2";
private static final String ACCRUAL_PERIODIC = "3";
private static final String MAKE_REPAYMENT_COMMAND = "repayment";
private static final String OVERPAID = "overpaid";
private static final String CLOSED_OBLIGATION_MET = "closedObligationsMet";
private static final String ACTIVE = "active";
/*
* loan disbursed: 4 installments of 3000; zero % interest; a specified due date charge of 120 is added and the
* amount is allocated to installment 2; allocation strategy is penalty, fees, interest, principal
*/
private static final Float oneInstallment = 3000.00f;
private static final Float fullLoan = 3000.00f * 4;
private static final Float fullChargeRefundAmount = 120.00f;
private static final Float oneThirdChargeRefundAmount = 40.00f;
private ResponseSpecification responseSpec;
private RequestSpecification requestSpec;
private LoanTransactionHelper loanTransactionHelper;
private LoanTransactionHelper loanTransactionHelperValidationError;
private AccountHelper accountHelper;
private JournalEntryHelper journalEntryHelper;
private Integer createdRepaymentTypeResourceId;
@BeforeEach
public void setup() {
Utils.initializeRESTAssured();
this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
this.loanTransactionHelperValidationError = new LoanTransactionHelper(this.requestSpec, new ResponseSpecBuilder().build());
this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build();
this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec);
this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec);
this.assetAccount = this.accountHelper.createAssetAccount();
this.feeIncomeAccount = this.accountHelper.createIncomeAccount();
this.penaltyIncomeAccount = this.accountHelper.createIncomeAccount();
this.expenseAccount = this.accountHelper.createExpenseAccount();
this.overpaymentAccount = this.accountHelper.createLiabilityAccount();
this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec);
}
@Test
public void fullRefundAndReverseOfPaidChargeSucceedsTest_Active_Active() {
testRefundAndReverseOfPaidChargeSucceeds(oneInstallment + fullChargeRefundAmount, fullChargeRefundAmount, ACTIVE, ACTIVE);
}
@Test
public void fullRefundAndReverseOfPaidChargeSucceedsTest_Active_Com() {
testRefundAndReverseOfPaidChargeSucceeds(fullLoan, fullChargeRefundAmount, ACTIVE, CLOSED_OBLIGATION_MET);
}
@Test
public void fullRefundAndReverseOfPaidChargeSucceedsTest_Active_Overpaid() {
testRefundAndReverseOfPaidChargeSucceeds(fullLoan + 50.00f, fullChargeRefundAmount, ACTIVE, OVERPAID);
}
@Test
public void fullRefundAndReverseOfPaidChargeSucceedsTest_Com_Overpaid() {
testRefundAndReverseOfPaidChargeSucceeds(fullLoan + fullChargeRefundAmount, fullChargeRefundAmount, CLOSED_OBLIGATION_MET,
OVERPAID);
}
@Test
public void fullRefundAndReverseOfPaidChargeSucceedsTest_Overpaid_Overpaid() {
testRefundAndReverseOfPaidChargeSucceeds(fullLoan + fullChargeRefundAmount + 50.00f, fullChargeRefundAmount, OVERPAID, OVERPAID);
}
@Test
public void partialRefundAndReverseOfPaidChargeSucceedsTest_Active_Active() {
testRefundAndReverseOfPaidChargeSucceeds(fullLoan, oneThirdChargeRefundAmount, ACTIVE, ACTIVE);
}
@Test
public void partialRefundAndReverseOfPaidChargeSucceedsTest_Active_Com() {
testRefundAndReverseOfPaidChargeSucceeds(fullLoan + (oneThirdChargeRefundAmount * 2), oneThirdChargeRefundAmount, ACTIVE,
CLOSED_OBLIGATION_MET);
}
@Test
public void partialRefundAndReverseOfPaidChargeSucceedsTest_Active_Overpaid() {
testRefundAndReverseOfPaidChargeSucceeds(fullLoan + (oneThirdChargeRefundAmount * 2) + 1.0f, oneThirdChargeRefundAmount, ACTIVE,
OVERPAID);
}
private void testRefundAndReverseOfPaidChargeSucceeds(final Float repaymentAmount, final Float refundAmount,
final String expectedPostRepaymentStatus, final String expectedPostRefundStatus) {
// disburse, repay, add charge, charge refund and reverse charge refund
Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, expectedPostRepaymentStatus, NONE, true);
Float totalOutstandingPreRefund = getLoanDetailsSummaryTotalOutstanding(disbursedLoanID);
Float overpaidPreRefund = getLoanDetailsTotalOverpaidAmount(disbursedLoanID);
Float expectedTotalOutstandingPostRefund = null;
Float expectedOverpaidPostRefund = null;
if (totalOutstandingPreRefund.compareTo(refundAmount) >= 0) {
expectedTotalOutstandingPostRefund = totalOutstandingPreRefund - refundAmount;
expectedOverpaidPostRefund = 0.0f;
} else {
expectedTotalOutstandingPostRefund = 0.0f;
if (totalOutstandingPreRefund == 0.0f) {
expectedOverpaidPostRefund = overpaidPreRefund + refundAmount;
} else {
expectedOverpaidPostRefund = refundAmount - totalOutstandingPreRefund;
}
}
LOG.info("-------------Loancharge Refund -----------");
final Integer installmentNumber = null;
final String externalId = null;
Integer chargeRefundTxnId = (Integer) this.loanTransactionHelper.loanChargeRefund(loanChargeId, installmentNumber, refundAmount,
externalId, this.disbursedLoanID, "resourceId");
HashMap loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID,
"status");
assertTrue((Boolean) loanStatusHashMap.get(expectedPostRefundStatus), "Invalid Post Refund Status");
Float totalOutstandingPostRefund = getLoanDetailsSummaryTotalOutstanding(disbursedLoanID);
Float overpaidPostRefund = getLoanDetailsTotalOverpaidAmount(disbursedLoanID);
Assertions.assertEquals(expectedTotalOutstandingPostRefund, totalOutstandingPostRefund, "Incorrect totalOutstanding Post Refund");
Assertions.assertEquals(expectedOverpaidPostRefund, overpaidPostRefund, "Incorrect overpaid Post Refund");
verifyPaidByEntry(disbursedLoanID, chargeRefundTxnId, refundAmount);
LOG.info("-------------Reverse Loancharge Refund -----------");
final String reverseDate = getTodaysDate();
final Float adjustmentAmount = 0.0f;
HashMap reverseHashMap = (HashMap) this.loanTransactionHelper.adjustLoanTransaction(disbursedLoanID, chargeRefundTxnId, reverseDate,
adjustmentAmount.toString(), "");
loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID,
"status");
assertTrue((Boolean) loanStatusHashMap.get(expectedPostRepaymentStatus), "Invalid Post Reversed Status");
Float totalOutstandingPostReverse = getLoanDetailsSummaryTotalOutstanding(disbursedLoanID);
Float overpaidPostReverse = getLoanDetailsTotalOverpaidAmount(disbursedLoanID);
Assertions.assertEquals(totalOutstandingPreRefund, totalOutstandingPostReverse, "Incorrect totalOutstanding Post Reverse");
Assertions.assertEquals(overpaidPreRefund, overpaidPostReverse, "Incorrect overpaid Post Reverse");
}
@Test
public void refundOfUnpaidChargeFailsTest() {
final Float repaymentAmount = 3000.00f; // pays installment one but none of added charge
Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, ACTIVE, NONE, false);
LOG.info("-------------Loancharge Refund -----------");
final Float refundAmount = 60.00f;
final Integer installmentNumber = null;
final String externalId = null;
ArrayList<HashMap> errors = (ArrayList<HashMap>) this.loanTransactionHelperValidationError.loanChargeRefund(loanChargeId,
installmentNumber, refundAmount, externalId, this.disbursedLoanID, CommonConstants.RESPONSE_ERROR);
assertEquals("error.msg.loan.charge.transaction.amount.is.more.than.is.refundable",
errors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE));
}
@Test
public void refundingMoreThanPaidFailsTest() {
final Float repaymentAmount = 3090.00f; // pays installment one and 90 (not all) of added charge
Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, ACTIVE, NONE, false);
LOG.info("-------------Loancharge Refund -----------");
final Float refundAmount = 90.01f; // 0.01 more than paid.
final Integer installmentNumber = null;
final String externalId = null;
ArrayList<HashMap> errors = (ArrayList<HashMap>) this.loanTransactionHelperValidationError.loanChargeRefund(loanChargeId,
installmentNumber, refundAmount, externalId, this.disbursedLoanID, CommonConstants.RESPONSE_ERROR);
assertEquals("error.msg.loan.charge.transaction.amount.is.more.than.is.refundable",
errors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE));
}
@Test
public void onyRefundElementNotRepaymentElementUsedToCalculateRefundableAmountTest() {
final Float chargeAmountPaid = 60.00f;
final Float repaymentAmount = 3000.00f + chargeAmountPaid;
// covers Installment 1 plus half of 120 charge added to installment 2
Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, ACTIVE, NONE, false);
LOG.info("-------------Loancharge Refund 1 -----------");
final Float refundAmount = chargeAmountPaid; // refund charge paid
final Integer installmentNumber = null;
final String externalId = null;
Integer chargeRefundTxnId = (Integer) this.loanTransactionHelper.loanChargeRefund(loanChargeId, installmentNumber, refundAmount,
externalId, this.disbursedLoanID, "resourceId");
HashMap loanDetailsHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec,
disbursedLoanID, "");
// refund 60 pays off remainder of charge leaving an amount 60 that could be refunded
LOG.info("-------------Loancharge Refund 2 -----------");
final Float smallRefund = 0.01f;
chargeRefundTxnId = (Integer) this.loanTransactionHelper.loanChargeRefund(loanChargeId, installmentNumber, smallRefund, externalId,
this.disbursedLoanID, "resourceId");
loanDetailsHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID, "");
}
@Test
public void refundOfPartiallyPaidChargeCanRepayMoreOfSameChargeTest() {
final Float chargeAmountPaid = 80.00f;
final Float chargeAmountFull = 120.00f;
final Float chargeAmountOutstanding = chargeAmountFull - chargeAmountPaid;
final Float repaymentAmount = 3000.00f + chargeAmountPaid;
// covers Installment 1 plus two thirds of 120 charge added to installment 2
Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, ACTIVE, NONE, false);
Float feeChargesPaid = getLoanDetailsSummaryfeeChargesPaid(disbursedLoanID);
Assertions.assertEquals(feeChargesPaid, chargeAmountPaid, "Incorrect Partial feeChargesPaid");
LOG.info("-------------Loancharge Refund -----------");
final Float refundAmount = chargeAmountPaid; // refund charge paid
final Integer installmentNumber = null;
final String externalId = null;
Integer chargeRefundTxnId = (Integer) this.loanTransactionHelper.loanChargeRefund(loanChargeId, installmentNumber, refundAmount,
externalId, this.disbursedLoanID, "resourceId");
feeChargesPaid = getLoanDetailsSummaryfeeChargesPaid(disbursedLoanID);
Assertions.assertEquals(feeChargesPaid, chargeAmountFull, "Incorrect Full feeChargesPaid");
ArrayList<HashMap> loanChargePaidByList = (ArrayList<HashMap>) this.loanTransactionHelper.getLoanTransactionDetails(disbursedLoanID,
chargeRefundTxnId, "loanChargePaidByList");
Assertions.assertNotNull(loanChargePaidByList);
Assertions.assertEquals(loanChargePaidByList.size(), 2);
// expecting 2 entries 1)-80 refund 2) 40 repayment
Float paidByAmount1 = (Float) loanChargePaidByList.get(0).get("amount");
Assertions.assertNotNull(paidByAmount1);
Assertions.assertNotEquals(paidByAmount1, 0.0f);
Float paidByAmount2 = (Float) loanChargePaidByList.get(1).get("amount");
Assertions.assertNotNull(paidByAmount2);
Assertions.assertNotEquals(paidByAmount2, 0.0f);
if (paidByAmount1 < 0.0f) {
Assertions.assertEquals(paidByAmount1, chargeAmountPaid * -1, "Refund Element Incorrect");
} else {
Assertions.assertEquals(paidByAmount1, chargeAmountOutstanding, "Repayment Element Incorrect");
}
if (paidByAmount2 < 0.0f) {
Assertions.assertEquals(paidByAmount2, chargeAmountPaid * -1, "Refund Element Incorrect");
} else {
Assertions.assertEquals(paidByAmount2, chargeAmountOutstanding, "Repayment Element Incorrect");
}
}
@Test
public void chargeRefundCreatesCorrectJournalEntriesForPeriodicAccruals_Fee_Test() {
chargeRefundCreatesCorrectJournalEntries(ACCRUAL_PERIODIC, false);
}
@Test
public void chargeRefundCreatesCorrectJournalEntriesForCashAccounting_Fee_Test() {
chargeRefundCreatesCorrectJournalEntries(CASH_BASED, false);
}
@Test
public void chargeRefundCreatesCorrectJournalEntriesForPeriodicAccruals_Penalty_Test() {
chargeRefundCreatesCorrectJournalEntries(ACCRUAL_PERIODIC, true);
}
@Test
public void chargeRefundCreatesCorrectJournalEntriesForCashAccounting_Penalty_Test() {
chargeRefundCreatesCorrectJournalEntries(CASH_BASED, true);
}
private void chargeRefundCreatesCorrectJournalEntries(final String accountingType, final boolean penalty) {
final Float repaymentAmount = fullLoan + fullChargeRefundAmount;
Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, CLOSED_OBLIGATION_MET, accountingType, penalty);
LOG.info("-------------Loancharge Refund -----------");
final Integer installmentNumber = null;
final String externalId = null;
Integer chargeRefundTxnId = (Integer) this.loanTransactionHelper.loanChargeRefund(loanChargeId, installmentNumber,
oneThirdChargeRefundAmount, externalId, this.disbursedLoanID, "resourceId");
final String txnDate = getTodaysDate();
Account incomeAccount = feeIncomeAccount;
if (penalty) {
incomeAccount = penaltyIncomeAccount;
}
this.journalEntryHelper.checkJournalEntryForAssetAccount(assetAccount, txnDate,
new JournalEntry(oneThirdChargeRefundAmount, JournalEntry.TransactionType.CREDIT));
this.journalEntryHelper.checkJournalEntryForLiabilityAccount(overpaymentAccount, txnDate,
new JournalEntry(oneThirdChargeRefundAmount, JournalEntry.TransactionType.CREDIT));
this.journalEntryHelper.checkJournalEntryForIncomeAccount(incomeAccount, txnDate,
new JournalEntry(oneThirdChargeRefundAmount, JournalEntry.TransactionType.DEBIT));
LOG.info("-------------Reverse Loancharge Refund -----------");
final String reverseDate = getTodaysDate();
final Float adjustmentAmount = 0.0f;
HashMap reverseHashMap = (HashMap) this.loanTransactionHelper.adjustLoanTransaction(disbursedLoanID, chargeRefundTxnId, reverseDate,
adjustmentAmount.toString(), "");
this.journalEntryHelper.checkJournalEntryForAssetAccount(assetAccount, txnDate,
new JournalEntry(oneThirdChargeRefundAmount, JournalEntry.TransactionType.DEBIT));
this.journalEntryHelper.checkJournalEntryForLiabilityAccount(overpaymentAccount, txnDate,
new JournalEntry(oneThirdChargeRefundAmount, JournalEntry.TransactionType.DEBIT));
this.journalEntryHelper.checkJournalEntryForIncomeAccount(incomeAccount, txnDate,
new JournalEntry(oneThirdChargeRefundAmount, JournalEntry.TransactionType.CREDIT));
}
@Test
public void repaymentReversalDisallowedIfLaterChargeRefundTest() {
// repayment covers 2 installments plus charge
final Float repaymentAmount = oneInstallment + oneInstallment + fullChargeRefundAmount;
final Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, ACTIVE, ACCRUAL_PERIODIC, false);
final Integer repayment1Id = createdRepaymentTypeResourceId;
final String repayment2Date = "20 January 2022";
final Float repayment2Amount = oneInstallment; // installment 3
makeRepaymentType(MAKE_REPAYMENT_COMMAND, repayment2Date, repayment2Amount);
Integer repayment2Id = createdRepaymentTypeResourceId;
LOG.info("-------------Loancharge Refund -----------");
final Integer installmentNumber = null;
final String externalId = null;
final Integer chargeRefundTxnId = (Integer) this.loanTransactionHelper.loanChargeRefund(loanChargeId, installmentNumber,
oneThirdChargeRefundAmount, externalId, this.disbursedLoanID, "resourceId");
final String reverseDate = getTodaysDate();
final Float adjustmentAmount = 0.0f;
LOG.info("-------------Reverse Repayment 2 -----------");
ArrayList<HashMap> errors = (ArrayList<HashMap>) this.loanTransactionHelperValidationError.adjustLoanTransaction(disbursedLoanID,
repayment2Id, reverseDate, adjustmentAmount.toString(), CommonConstants.RESPONSE_ERROR);
assertEquals("error.msg.loan.transaction.cant.be.reversed.because.later.charge.refund.exists",
errors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE));
}
@Test
public void repaymentNotAllowedIfLaterChargeRefundTest() {
// repayment covers 2 installments plus charge
final Float repaymentAmount = oneInstallment + oneInstallment + fullChargeRefundAmount;
final Integer loanChargeId = disburseAddChargeAndRepay(repaymentAmount, ACTIVE, ACCRUAL_PERIODIC, false);
LOG.info("-------------Loancharge Refund -----------");
final Integer installmentNumber = null;
final String externalId = null;
final Integer chargeRefundTxnId = (Integer) this.loanTransactionHelper.loanChargeRefund(loanChargeId, installmentNumber,
oneThirdChargeRefundAmount, externalId, this.disbursedLoanID, "resourceId");
final String repayment2Date = "20 January 2022";
final Float repayment2Amount = oneInstallment; // installment 3
ArrayList<HashMap> errors = (ArrayList<HashMap>) this.loanTransactionHelperValidationError.makeRepaymentTypePayment(
MAKE_REPAYMENT_COMMAND, repayment2Date, repayment2Amount, this.disbursedLoanID, CommonConstants.RESPONSE_ERROR);
assertEquals("error.msg.loan.transaction.cant.be.created.because.later.charge.refund.exists",
errors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE));
}
private void disburseLoanOfAccountingRule(final String accountingType, final String loanAmount, final String loanDate,
final boolean penalty) {
this.disbursedLoanID = fromStartToDisburseLoan(loanDate, loanAmount, penalty, accountingType, assetAccount, feeIncomeAccount,
expenseAccount, overpaymentAccount);
}
private Integer fromStartToDisburseLoan(String submitApproveDisburseDate, String principal, final boolean penalty,
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);
// if (penalty) {
LOG.info("-----------------------------------Setting Specific Penalty Income Account-----------------------------------------");
final String accountId = penaltyIncomeAccount.getAccountID().toString();
final String putURL = "/fineract-provider/api/v1/loanproducts/" + loanProductID + "?" + Utils.TENANT_IDENTIFIER;
final HashMap<String, Object> map = new HashMap<>();
map.put("incomeFromPenaltyAccountId", accountId);
final String jsonBodyToSend = new Gson().toJson(map);
Utils.performServerPut(requestSpec, responseSpec, putURL, jsonBodyToSend);
// }
final List<HashMap> charges = null;
final Integer loanID = applyForLoanApplication(clientID, loanProductID, charges, 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 makeRepaymentType(final String repaymentTypeCommand, final String repaymentDate, final Float repayment) {
LOG.info("-------------Make repayment Type -----------");
createdRepaymentTypeResourceId = (Integer) this.loanTransactionHelper.makeRepaymentTypePayment(repaymentTypeCommand, repaymentDate,
repayment, this.disbursedLoanID, "resourceId");
HashMap loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, disbursedLoanID,
"status");
return loanStatusHashMap;
}
private static String getLoanChargeAsJSON(final String chargeId, final String dueDate, final String amount, final String externalId) {
final HashMap<String, String> map = new HashMap<>();
map.put("locale", "en_GB");
map.put("dateFormat", "dd MMMM yyyy");
map.put("amount", amount);
map.put("dueDate", dueDate);
map.put("chargeId", chargeId);
map.put("externalId", externalId);
String json = new Gson().toJson(map);
LOG.info("{}", json);
return json;
}
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("0") //
.withInterestRateFrequencyTypeAsMonths() //
.withAmortizationTypeAsEqualInstallments() //
.withInterestTypeAsDecliningBalance() //
.withTranches(multiDisburseLoan) //
.withAccounting(accountingRule, accounts);
if (multiDisburseLoan) {
builder = builder.withInterestCalculationPeriodTypeAsRepaymentPeriod(true);
}
final String loanProductJSON = builder.build(null);
return this.loanTransactionHelper.getLoanProductId(loanProductJSON);
}
private Integer applyForLoanApplication(final Integer clientID, final Integer loanProductID, List<HashMap> charges, String principal,
String loanDate) {
LOG.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------");
final String savingsId = null;
final String loanApplicationJSON = new LoanApplicationTestBuilder() //
.withPrincipal(principal) //
.withLoanTermFrequency("4") //
.withLoanTermFrequencyAsMonths() //
.withNumberOfRepayments(FOUR_INSTALLMENTS) //
.withRepaymentEveryAfter("1") //
.withRepaymentFrequencyTypeAsMonths() //
.withInterestRatePerPeriod(ZERO_INTEREST_RATE) //
.withAmortizationTypeAsEqualInstallments() //
.withInterestTypeAsDecliningBalance() //
.withInterestCalculationPeriodTypeSameAsRepaymentPeriod() //
.withExpectedDisbursementDate(loanDate) //
.withSubmittedOnDate(loanDate) //
.withCharges(charges).build(clientID.toString(), loanProductID.toString(), savingsId);
return this.loanTransactionHelper.getLoanId(loanApplicationJSON);
}
private Integer disburseAddChargeAndRepay(final Float repaymentAmount, final String expectedPostRepaymentStatus,
final String accountingType, final boolean penalty) {
final String loanDate = "01 January 2022";
final String loanAmount = "12,000.00";
disburseLoanOfAccountingRule(accountingType, loanAmount, loanDate, penalty);
final Integer charge = ChargesHelper.createCharges(requestSpec, responseSpec, ChargesHelper
.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_PERCENTAGE_AMOUNT_AND_INTEREST, "1", penalty));
final String externalId = null;
final float amount = 1.0f;
final String chargeDueDate = "15 February 2022"; // will be added to the 2nd installment (March)
final Integer loanChargeId = this.loanTransactionHelper.addChargesForLoan(this.disbursedLoanID,
getLoanChargeAsJSON(String.valueOf(charge), chargeDueDate, String.valueOf(amount), externalId));
Assertions.assertNotNull(loanChargeId);
final String repaymentDate = "10 January 2022";
HashMap loanStatusHashMap = makeRepaymentType(MAKE_REPAYMENT_COMMAND, repaymentDate, repaymentAmount);
assertTrue((Boolean) loanStatusHashMap.get(expectedPostRepaymentStatus));
return loanChargeId;
}
private Float getLoanDetailsSummaryTotalOutstanding(final Integer loanId) {
HashMap loanDetailsHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, loanId,
"summary");
Float amount = (Float) loanDetailsHashMap.get("totalOutstanding");
if (amount == null) {
amount = 0.0f;
}
return amount;
}
private Float getLoanDetailsSummaryfeeChargesPaid(final Integer loanId) {
HashMap loanDetailsHashMap = (HashMap) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, loanId,
"summary");
Float amount = (Float) loanDetailsHashMap.get("feeChargesPaid");
if (amount == null) {
amount = 0.0f;
}
return amount;
}
private Float getLoanDetailsTotalOverpaidAmount(final Integer loanId) {
Float amount = (Float) this.loanTransactionHelper.getLoanDetail(this.requestSpec, this.responseSpec, loanId, "totalOverpaid");
if (amount == null) {
amount = 0.0f;
}
return amount;
}
private void verifyPaidByEntry(final Integer loanId, final Integer chargeRefundTxnId, final Float refundAmount) {
ArrayList<HashMap> loanChargePaidByList = (ArrayList<HashMap>) this.loanTransactionHelper.getLoanTransactionDetails(loanId,
chargeRefundTxnId, "loanChargePaidByList");
Assertions.assertNotNull(loanChargePaidByList);
Assertions.assertEquals(loanChargePaidByList.size(), 1);
Float paidByAmount = (Float) loanChargePaidByList.get(0).get("amount") * -1;
Assertions.assertEquals(refundAmount, paidByAmount, "Incorrect Paid By Amount");
}
private String getTodaysDate() {
DateFormat dateFormat = new SimpleDateFormat("dd MMMM yyyy", Locale.US);
dateFormat.setTimeZone(Utils.getTimeZoneOfTenant());
Calendar todaysDate = Calendar.getInstance(Utils.getTimeZoneOfTenant());
return dateFormat.format(todaysDate.getTime());
}
}
@@ -263,7 +263,7 @@ public class ClientLoanCreditBalanceRefundandRepaymentTypeIntegrationTest {
disbursedLoanID, "resourceId");
Assertions.assertNotNull(resourceId);
HashMap creditBalanceRefundMap = this.loanTransactionHelper.getLoanTransactionDetails(disbursedLoanID, resourceId);
HashMap creditBalanceRefundMap = (HashMap) this.loanTransactionHelper.getLoanTransactionDetails(disbursedLoanID, resourceId, "");
Assertions.assertNotNull(creditBalanceRefundMap.get("externalId"));
Assertions.assertEquals(creditBalanceRefundMap.get("externalId"), externalId, "Incorrect External Id Saved");
@@ -347,7 +347,7 @@ public class ClientLoanCreditBalanceRefundandRepaymentTypeIntegrationTest {
HashMap loanStatusHashMap = (HashMap) this.loanTransactionHelper.makeRepaymentTypePayment(repaymentTransactionType,
"06 January 2022", 200.00f, this.disbursedLoanID, "");
Integer newTransactionId = (Integer) loanStatusHashMap.get("resourceId");
loanStatusHashMap = this.loanTransactionHelper.getLoanTransactionDetails(this.disbursedLoanID, newTransactionId);
loanStatusHashMap = (HashMap) this.loanTransactionHelper.getLoanTransactionDetails(this.disbursedLoanID, newTransactionId, "");
HashMap typeMap = (HashMap) loanStatusHashMap.get("type");
Boolean isTypeCorrect = (Boolean) typeMap.get(repaymentTransactionType);
@@ -63,6 +63,7 @@ public class LoanTransactionHelper {
private static final String WAIVE_INTEREST_COMMAND = "waiveinterest";
private static final String MAKE_REPAYMENT_COMMAND = "repayment";
private static final String UNDO = "undo";
private static final String LOANCHARGE_REFUND_REPAYMENT_COMMAND = "chargeRefund";
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";
@@ -349,6 +350,12 @@ public class LoanTransactionHelper {
getCreditBalanceRefundBodyAsJSON(date, amountToBePaid, externalId), jsonAttributeToGetback);
}
public Object loanChargeRefund(final Integer loanChargeId, final Integer installmentNumber, final Float amountToBePaid,
final String externalId, final Integer loanID, String jsonAttributeToGetback) {
return performLoanTransaction(createLoanTransactionURL(LOANCHARGE_REFUND_REPAYMENT_COMMAND, loanID),
getLoanChargeRefundBodyAsJSON(loanChargeId, installmentNumber, amountToBePaid, externalId), jsonAttributeToGetback);
}
public Object makeRepaymentTypePayment(final String repaymentTypeCommand, final String date, final Float amountToBePaid,
final Integer loanID, String jsonAttributeToGetback) {
return performLoanTransaction(createLoanTransactionURL(repaymentTypeCommand, loanID), getRepaymentBodyAsJSON(date, amountToBePaid),
@@ -452,10 +459,10 @@ public class LoanTransactionHelper {
return Utils.performServerGet(requestSpec, responseSpec, GET_LOAN_CHARGES_URL, "");
}
public HashMap getLoanTransactionDetails(final Integer loanId, final Integer txnId) {
public Object getLoanTransactionDetails(final Integer loanId, final Integer txnId, final String param) {
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, "");
return Utils.performServerGet(requestSpec, responseSpec, GET_LOAN_CHARGES_URL, param);
}
public HashMap getPostDatedCheck(final Integer loanId, final Integer installmentId) {
@@ -535,6 +542,23 @@ public class LoanTransactionHelper {
return new Gson().toJson(map);
}
private String getLoanChargeRefundBodyAsJSON(final Integer loanChargeId, final Integer installmentNumber, 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("loanChargeId", loanChargeId.toString());
map.put("transactionAmount", transactionAmount.toString());
map.put("note", "Loancharge Refund Made!!!");
if (externalId != null) {
map.put("externalId", externalId);
}
if (installmentNumber != null) {
map.put("installmentNumber", installmentNumber.toString());
}
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");