From 7fd8db6086bf4a059f66685c27b3a1d55f19d0c1 Mon Sep 17 00:00:00 2001 From: Ruchi Dhamankar Date: Mon, 10 Jun 2024 22:34:09 +0530 Subject: [PATCH] FINERACT-1971: Wrong GL entries on refund with reverse replay and charge off --- .../portfolio/loanaccount/domain/Loan.java | 30 ++- .../loanaccount/domain/LoanTransaction.java | 19 ++ ...iesForReverseReplayedTransactionsTest.java | 215 ++++++++++++++++++ 3 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingEntriesForReverseReplayedTransactionsTest.java diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index 1d07b282efb..1810c7a7e5a 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -60,6 +60,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; import lombok.Getter; import lombok.Setter; import org.apache.commons.lang3.StringUtils; @@ -4329,18 +4330,39 @@ private void filterTransactionsByChargeOffDate(List> newLoan LoanTransaction chargeOffTransaction = this.loanTransactions.stream().filter(LoanTransaction::isChargeOff) .filter(LoanTransaction::isNotReversed).findFirst().get(); + LoanTransaction originalChargeOffTransaction = getOriginalTransactionIfReverseReplayed(chargeOffTransaction); + this.loanTransactions.stream().filter(chargeOffDateCriteria).forEach(transaction -> { boolean isExistingTransaction = existingTransactionIds.contains(transaction.getId()); boolean isExistingReversedTransaction = existingReversedTransactionIds.contains(transaction.getId()); - List> targetList = (transaction.getId().compareTo(chargeOffTransaction.getId()) < 0) - ? newLoanTransactionsBeforeChargeOff - : newLoanTransactionsAfterChargeOff; - if ((transaction.isReversed() && isExistingTransaction && !isExistingReversedTransaction) || !isExistingTransaction) { + List> targetList = null; + if ((transaction.isReversed() && isExistingTransaction && !isExistingReversedTransaction)) { + // reversed transactions + LoanTransaction originalTransaction = getOriginalTransactionIfReverseReplayed(transaction); + targetList = originalTransaction.happenedBefore(originalChargeOffTransaction) ? newLoanTransactionsBeforeChargeOff + : newLoanTransactionsAfterChargeOff; + + } else if (!isExistingTransaction) { + // new and replayed transactions + targetList = transaction.happenedBefore(chargeOffTransaction) ? newLoanTransactionsBeforeChargeOff + : newLoanTransactionsAfterChargeOff; + } + if (targetList != null) { targetList.add(transaction.toMapData(currencyCode)); } }); } + private LoanTransaction getOriginalTransactionIfReverseReplayed(LoanTransaction loanTransaction) { + if (!loanTransaction.getLoanTransactionRelations().isEmpty()) { + return loanTransaction.getLoanTransactionRelations().stream() + .filter(tr -> LoanTransactionRelationTypeEnum.REPLAYED.equals(tr.getRelationType())).map(tr -> tr.getToTransaction()) + .collect(Collectors.toList()).stream().sorted(Comparator.comparingLong(LoanTransaction::getId)).findFirst() + .orElse(loanTransaction); + } + return loanTransaction; + } + public Map deriveAccountingBridgeData(final String currencyCode, final List existingTransactionIds, final List existingReversedTransactionIds, boolean isAccountTransfer) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index c1b6c32e7f9..8b25e7e4446 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -1016,6 +1016,25 @@ public void setLoanReAgeParameter(LoanReAgeParameter loanReAgeParameter) { this.loanReAgeParameter = loanReAgeParameter; } + public boolean happenedBefore(LoanTransaction loanTransaction) { + // compare transaction date, creation date and then transaction id + if (DateUtils.isBefore(getTransactionDate(), loanTransaction.getTransactionDate())) { + return true; + } + if (DateUtils.isEqual(getTransactionDate(), loanTransaction.getTransactionDate())) { + if (DateUtils.isBefore(getCreatedDateTime(), loanTransaction.getCreatedDateTime())) { + return true; + } + if (DateUtils.isEqual(getCreatedDateTime(), loanTransaction.getCreatedDateTime())) { + if (getId().compareTo(loanTransaction.getId()) < 0) { + return true; + } + } + } + + return false; + } + // TODO missing hashCode(), equals(Object obj), but probably OK as long as // this is never stored in a Collection. } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingEntriesForReverseReplayedTransactionsTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingEntriesForReverseReplayedTransactionsTest.java new file mode 100644 index 00000000000..a5a6999b592 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingEntriesForReverseReplayedTransactionsTest.java @@ -0,0 +1,215 @@ +/** + * 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 java.math.BigDecimal; +import java.util.List; +import java.util.UUID; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.junit.jupiter.api.Test; + +public class LoanChargeOffAccountingEntriesForReverseReplayedTransactionsTest extends BaseLoanIntegrationTest { + + @Test + public void testJournalEntriesForChargeOffLoanWithMultipleReverseReplay() { + runAt("24 May 2024", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create DelinquencyBuckets + Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec, List.of(// + Pair.of(1, 10), // + Pair.of(11, 30), // + Pair.of(31, 60), // + Pair.of(61, null)// + )); + + // create loan product + PostLoanProductsRequest loanProductsRequest = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct().numberOfRepayments(3)// + .repaymentEvery(15)// + .repaymentFrequencyType(RepaymentFrequencyType.DAYS.longValue())// + .disallowExpectedDisbursements(true)// + .multiDisburseLoan(true)// + .enableDownPayment(true)// + .disbursedAmountPercentageForDownPayment(BigDecimal.valueOf(25.0))// + .enableAutoRepaymentForDownPayment(true); + + loanProductsRequest.setDelinquencyBucketId(delinquencyBucketId.longValue()); + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "24 May 2024", 1000.0, 3); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(200), "24 May 2024"); + + // verify transactions + verifyTransactions(loanId, // + transaction(50.0, "Down Payment", "24 May 2024"), // + transaction(200.0, "Disbursement", "24 May 2024") // + ); + + // set business date 25 May + updateBusinessDate("25 May 2024"); + + // reverse downpayment transaction + Long downPaymentTransactionId = getTransactionId(loanId, "Down Payment", "24 May 2024"); + loanTransactionHelper.reverseLoanTransaction(loanId, downPaymentTransactionId, + new PostLoansLoanIdTransactionsTransactionIdRequest().dateFormat(DATETIME_PATTERN).transactionDate("25 May 2024") + .transactionAmount(0.0).locale("en")); + + // verify transactions + verifyTransactions(loanId, // + transaction(200.0, "Disbursement", "24 May 2024", 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(50.0, "Down Payment", "24 May 2024", 150.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, true) // + ); + + // set business date 26 May + updateBusinessDate("26 May 2024"); + // charge-off loan + Long chargeOffTransactionId = chargeOffLoan(loanId, "26 May 2024"); + + // verify transactions + verifyTransactions(loanId, // + transaction(200.0, "Disbursement", "24 May 2024", 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(50.0, "Down Payment", "24 May 2024", 150.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, true), // + transaction(200.0, "Charge-off", "26 May 2024", 0.0, 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, false) // + ); + + // make backdated repayment on 25 May + Long repaymentTransactionId = addRepaymentForLoan(loanId, 10.0, "25 May 2024"); + + // verify transactions + verifyTransactions(loanId, // + transaction(200.0, "Disbursement", "24 May 2024", 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(50.0, "Down Payment", "24 May 2024", 150.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, true), // + transaction(10.0, "Repayment", "25 May 2024", 190.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(190.0, "Charge-off", "26 May 2024", 0.0, 190.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)); + + // make refund on 26 May equal to disbursal amount + String merchantIssuedRefundExternalId = UUID.randomUUID().toString(); + Long merchantIssuedRefundId = loanTransactionHelper.makeMerchantIssuedRefund(loanId, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("26 May 2024").locale("en") + .transactionAmount(200.0).externalId(merchantIssuedRefundExternalId)) + .getResourceId(); + + // verify transactions + verifyTransactions(loanId, // + transaction(200.0, "Disbursement", "24 May 2024", 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(50.0, "Down Payment", "24 May 2024", 150.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, true), // + transaction(10.0, "Repayment", "25 May 2024", 190.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(190.0, "Charge-off", "26 May 2024", 0.0, 190.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(200.0, "Merchant Issued Refund", "26 May 2024", 0.0, 190.0, 0.0, 0.0, 0.0, 0.0, 10.0, false)); + + // verify loan status is overpaid + verifyLoanStatus(loanId, LoanStatus.OVERPAID); + // verify journal entries + + // repayment + verifyTRJournalEntries(repaymentTransactionId, // + credit(loansReceivableAccount, 10), // + debit(fundSource, 10) // + ); + + // charge off + verifyTRJournalEntries(chargeOffTransactionId, // + credit(loansReceivableAccount, 200), // + debit(chargeOffExpenseAccount, 200), // + debit(loansReceivableAccount, 200), // + credit(chargeOffExpenseAccount, 200) // + ); + + // refund + verifyTRJournalEntries(merchantIssuedRefundId, // + credit(chargeOffExpenseAccount, 190), // + credit(overpaymentAccount, 10), // + debit(fundSource, 200) + + ); + + // set business date 27 May + updateBusinessDate("27 May 2024"); + + // CBR + loanTransactionHelper.makeCreditBalanceRefund(loanId, new PostLoansLoanIdTransactionsRequest().transactionDate("27 May 2024") + .dateFormat(DATETIME_PATTERN).transactionAmount(10.0).locale("en")); + + // verify loan status is closed + verifyLoanStatus(loanId, LoanStatus.CLOSED_OBLIGATIONS_MET); + + // verify transactions + verifyTransactions(loanId, // + transaction(200.0, "Disbursement", "24 May 2024", 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(50.0, "Down Payment", "24 May 2024", 150.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, true), // + transaction(10.0, "Repayment", "25 May 2024", 190.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(190.0, "Charge-off", "26 May 2024", 0.0, 190.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(200.0, "Merchant Issued Refund", "26 May 2024", 0.0, 190.0, 0.0, 0.0, 0.0, 0.0, 10.0, false), // + transaction(10.0, "Credit Balance Refund", "27 May 2024", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 10.0, false)); + + // reverse backdated repayment + loanTransactionHelper.reverseLoanTransaction(loanId, repaymentTransactionId, + new PostLoansLoanIdTransactionsTransactionIdRequest().dateFormat(DATETIME_PATTERN).transactionDate("27 May 2024") + .transactionAmount(0.0).locale("en")); + + // verify transactions + verifyTransactions(loanId, // + transaction(200.0, "Disbursement", "24 May 2024", 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(50.0, "Down Payment", "24 May 2024", 150.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, true), // + transaction(10.0, "Repayment", "25 May 2024", 190.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, true), // + transaction(200.0, "Charge-off", "26 May 2024", 0.0, 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(200.0, "Merchant Issued Refund", "26 May 2024", 0.0, 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, false), // + transaction(10.0, "Credit Balance Refund", "27 May 2024", 10.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)); + + // verify journal entries + + // repayment + verifyTRJournalEntries(repaymentTransactionId, // + credit(loansReceivableAccount, 10), // + debit(fundSource, 10), // + debit(loansReceivableAccount, 10), // + credit(fundSource, 10) // + ); + + // replayed merchant issued refund + Long replayedMerchantIssuedRefundId = loanTransactionHelper.getLoanTransactionDetails(loanId, merchantIssuedRefundExternalId) + .getId(); + + verifyTRJournalEntries(replayedMerchantIssuedRefundId, credit(chargeOffExpenseAccount, 200), // + debit(fundSource, 200) // + ); + + // verify journal entries for reversed refund + verifyTRJournalEntries(merchantIssuedRefundId, // + credit(chargeOffExpenseAccount, 190), // + credit(overpaymentAccount, 10), // + debit(fundSource, 200), // + debit(chargeOffExpenseAccount, 190), // + debit(overpaymentAccount, 10), // + credit(fundSource, 200)); + + }); + } +}