FINERACT-2210: Fix incorrect accounting of "buyback" for charged-off loan with fraud

This commit is contained in:
mariiaKraievska
2025-04-24 11:46:14 +02:00
committed by Adam Saghy
parent 30543fd72e
commit 13c99f98ab
4 changed files with 153 additions and 40 deletions
@@ -1274,7 +1274,7 @@ public class LoanProductsRequestFactory {
final List<PostChargeOffReasonToExpenseAccountMappings> chargeOffReasonToExpenseAccountMappings = new ArrayList<>();
final PostChargeOffReasonToExpenseAccountMappings chargeOffDelinquentReason = new PostChargeOffReasonToExpenseAccountMappings();
chargeOffDelinquentReason.chargeOffReasonCodeValueId(codeValueResolver.resolve(chargeOffReasonId, DefaultCodeValue.DELINQUENT));
chargeOffDelinquentReason.expenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD));
chargeOffDelinquentReason.expenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT));
chargeOffReasonToExpenseAccountMappings.add(chargeOffDelinquentReason);
return new PostLoanProductsRequest()//
@@ -1369,7 +1369,7 @@ public class LoanProductsRequestFactory {
.incomeFromChargeOffInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))//
.incomeFromChargeOffFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.chargeOffExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))//
.chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))//
.chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))//
.chargeOffReasonToExpenseAccountMappings(chargeOffReasonToExpenseAccountMappings)//
.incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));//
}
@@ -7647,7 +7647,7 @@ Feature: Charge-off
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | | 100.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 0.62 |
| EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 100.0 | |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 100.0 | |
| INCOME | 404001 | Interest Income Charge Off | 0.62 | |
Then Admin does a charge-off undo the loan
Then Loan Transactions tab has a "CHARGE_OFF" transaction with date "03 February 2024" which has the following Journal entries:
@@ -7656,8 +7656,8 @@ Feature: Charge-off
| ASSET | 112601 | Loans Receivable | 100.0 | |
| ASSET | 112603 | Interest/Fee Receivable | | 0.62 |
| ASSET | 112603 | Interest/Fee Receivable | 0.62 | |
| EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 100.0 | |
| EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 100.0 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 100.0 | |
| EXPENSE | 744007 | Credit Loss/Bad Debt | | 100.0 |
| INCOME | 404001 | Interest Income Charge Off | 0.62 | |
| INCOME | 404001 | Interest Income Charge Off | | 0.62 |
@@ -8345,3 +8345,98 @@ Feature: Charge-off
| 14 April 2025 | Accrual | 3.26 | 0.0 | 3.26 | 0.0 | 0.0 | 0.0 | false | false |
| 14 April 2025 | Accrual Adjustment | 0.1 | 0.0 | 0.1 | 0.0 | 0.0 | 0.0 | false | false |
| 14 April 2025 | Charge-off | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | true |
@TestRailId:C3618
Scenario: Charge-off on a fraud loan respects GL mapping based on charge-off reason for buyback
When Admin sets the business date to "1 January 2024"
And Admin creates a client with random data
And Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_ADV_PYMNT_ZERO_INTEREST_CHARGE_OFF_DELINQUENT_REASON | 01 January 2024 | 100 | 7 | DECLINING_BALANCE | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
Then Loan Repayment schedule has 6 periods, with the following data for periods:
| Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| | | 01 January 2024 | | 100.0 | | | 0.0 | | 0.0 | | | | 0.0 |
| 1 | 31 | 01 February 2024 | | 83.59 | 16.41 | 0.59 | 0.0 | 0.0 | 17.0 | 0.0 | 0.0 | 0.0 | 17.0 |
| 2 | 29 | 01 March 2024 | | 67.05 | 16.54 | 0.46 | 0.0 | 0.0 | 17.0 | 0.0 | 0.0 | 0.0 | 17.0 |
| 3 | 31 | 01 April 2024 | | 50.45 | 16.6 | 0.4 | 0.0 | 0.0 | 17.0 | 0.0 | 0.0 | 0.0 | 17.0 |
| 4 | 30 | 01 May 2024 | | 33.74 | 16.71 | 0.29 | 0.0 | 0.0 | 17.0 | 0.0 | 0.0 | 0.0 | 17.0 |
| 5 | 31 | 01 June 2024 | | 16.94 | 16.8 | 0.2 | 0.0 | 0.0 | 17.0 | 0.0 | 0.0 | 0.0 | 17.0 |
| 6 | 30 | 01 July 2024 | | 0.0 | 16.94 | 0.1 | 0.0 | 0.0 | 17.04 | 0.0 | 0.0 | 0.0 | 17.04 |
Then Loan Repayment schedule has the following data in Total row:
| Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| 100 | 2.04 | 0 | 0 | 102.04 | 0 | 0 | 0 | 102.04 |
And Admin successfully approves the loan on "1 January 2024" with "100" amount and expected disbursement date on "1 January 2024"
And Admin successfully disburse the loan on "1 January 2024" with "100" EUR transaction amount
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
| Transaction type | settlementDate | purchasePriceRatio |
| sale | 2024-01-21 | 1 |
Then Asset externalization response has the correct Loan ID, transferExternalId
Then Fetching Asset externalization details by loan id gives numberOfElements: 1 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2024-01-21 | 1 | PENDING | 2024-01-01 | 9999-12-31 | SALE |
When Admin sets the business date to "22 January 2024"
When Admin runs inline COB job for Loan
Then LoanOwnershipTransferBusinessEvent is created
Then LoanAccountSnapshotBusinessEvent is created
Then Fetching Asset externalization details by loan id gives numberOfElements: 2 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2024-01-21 | 1 | PENDING | 2024-01-01 | 2024-01-21 | SALE |
| 2024-01-21 | 1 | ACTIVE | 2024-01-22 | 9999-12-31 | SALE |
Then The latest asset externalization transaction with "ACTIVE" status has the following TRANSFER Journal entries:
| glAccountType | glAccountCode | glAccountName | entryType | amount |
| ASSET | 112601 | Loans Receivable | CREDIT | 100.00 |
| ASSET | 112603 | Interest/Fee Receivable | CREDIT | 2.04 |
| ASSET | 146000 | Asset transfer | DEBIT | 102.04 |
| ASSET | 112601 | Loans Receivable | DEBIT | 100.00 |
| ASSET | 112603 | Interest/Fee Receivable | DEBIT | 2.04 |
| ASSET | 146000 | Asset transfer | CREDIT | 102.04 |
Then The asset external owner has the following OWNER Journal entries:
| glAccountType | glAccountCode | glAccountName | entryType | amount |
| ASSET | 112601 | Loans Receivable | DEBIT | 100.00 |
| ASSET | 112603 | Interest/Fee Receivable | DEBIT | 2.04 |
Then Admin can successfully set Fraud flag to the loan
When Admin sets the business date to "03 February 2024"
And Admin does charge-off the loan with reason "DELINQUENT" on "03 February 2024"
Then Loan Transactions tab has a "CHARGE_OFF" transaction with date "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | | 100.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 0.62 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 100.0 | |
| INCOME | 404001 | Interest Income Charge Off | 0.62 | |
When Admin makes asset externalization request by Loan ID with unique ownerExternalId, system-generated transferExternalId and the following data:
| Transaction type | settlementDate | purchasePriceRatio |
| buyback | 2024-02-03 | |
Then Fetching Asset externalization details by loan id gives numberOfElements: 3 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2024-01-21 | 1 | PENDING | 2024-01-01 | 2024-01-21 | SALE |
| 2024-01-21 | 1 | ACTIVE | 2024-01-22 | 9999-12-31 | SALE |
| 2024-02-03 | 1 | BUYBACK | 2024-02-03 | 9999-12-31 | BUYBACK |
When Admin sets the business date to "04 May 2024"
When Admin runs inline COB job for Loan
Then LoanOwnershipTransferBusinessEvent is created
Then LoanAccountSnapshotBusinessEvent is created
Then Fetching Asset externalization details by loan id gives numberOfElements: 3 with correct ownerExternalId and the following data:
| settlementDate | purchasePriceRatio | status | effectiveFrom | effectiveTo | Transaction type |
| 2024-01-21 | 1 | PENDING | 2024-01-01 | 2024-01-21 | SALE |
| 2024-01-21 | 1 | ACTIVE | 2024-01-22 | 2024-02-03 | SALE |
| 2024-02-03 | 1 | BUYBACK | 2024-02-03 | 2024-02-03 | BUYBACK |
Then The latest asset externalization transaction with "BUYBACK" status has the following TRANSFER Journal entries:
| glAccountType | glAccountCode | glAccountName | entryType | amount |
| EXPENSE | 744007 | Credit Loss/Bad Debt | DEBIT | 100.00 |
| INCOME | 404001 | Interest Income Charge Off | DEBIT | 0.62 |
| ASSET | 146000 | Asset transfer | CREDIT | 100.62 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | CREDIT | 100.00 |
| INCOME | 404001 | Interest Income Charge Off | CREDIT | 0.62 |
| ASSET | 146000 | Asset transfer | DEBIT | 100.62 |
Then The asset external owner has the following OWNER Journal entries:
| glAccountType | glAccountCode | glAccountName | entryType | amount |
| ASSET | 112601 | Loans Receivable | DEBIT | 100.00 |
| ASSET | 112603 | Interest/Fee Receivable | DEBIT | 2.04 |
| ASSET | 112603 | Interest/Fee Receivable | DEBIT | 0.24 |
| INCOME | 404000 | Interest Income | CREDIT | 0.24 |
| ASSET | 112601 | Loans Receivable | CREDIT | 100.00 |
| ASSET | 112603 | Interest/Fee Receivable | CREDIT | 0.62 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | DEBIT | 100.00 |
| INCOME | 404001 | Interest Income Charge Off | DEBIT | 0.62 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | CREDIT | 100.00 |
| INCOME | 404001 | Interest Income Charge Off | CREDIT | 0.62 |
@@ -90,6 +90,11 @@ public class InvestorAccountingHelper {
}
}
public ProductToGLAccountMapping getChargeOffMappingByCodeValue(final Long loanProductId, final PortfolioProductType productType,
final Long chargeOffReasonId) {
return accountMappingRepository.findChargeOffReasonMapping(loanProductId, productType.getValue(), chargeOffReasonId);
}
private JournalEntry createCreditJournalEntryForInvestor(final Office office, final String currencyCode, final GLAccount account,
final Long loanId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) {
final boolean manualEntry = false;
@@ -31,6 +31,8 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.investor.accounting.journalentry.service.InvestorAccountingHelper;
import org.apache.fineract.investor.domain.ExternalAssetOwner;
import org.apache.fineract.investor.domain.ExternalAssetOwnerJournalEntryMapping;
@@ -39,6 +41,7 @@ import org.apache.fineract.investor.domain.ExternalAssetOwnerTransfer;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferJournalEntryMapping;
import org.apache.fineract.investor.domain.ExternalAssetOwnerTransferJournalEntryMappingRepository;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.jetbrains.annotations.NotNull;
@@ -94,7 +97,8 @@ public class AccountingServiceImpl implements AccountingService {
}
@NotNull
private List<JournalEntry> createJournalEntries(Loan loan, ExternalAssetOwnerTransfer transfer, boolean isReversalOrder) {
private List<JournalEntry> createJournalEntries(final Loan loan, final ExternalAssetOwnerTransfer transfer,
final boolean isReversalOrder) {
this.helper.checkForBranchClosures(loan.getOffice().getId(), transfer.getSettlementDate());
// transaction properties
final Long transactionId = transfer.getId();
@@ -106,8 +110,8 @@ public class AccountingServiceImpl implements AccountingService {
final BigDecimal overPaymentAmount = loan.getTotalOverpaid();
// Moving money to asset transfer account
List<JournalEntry> journalEntryList = createJournalEntries(loan, transactionId, transactionDate, principalAmount, interestAmount,
feesAmount, penaltiesAmount, overPaymentAmount, !isReversalOrder);
final List<JournalEntry> journalEntryList = createJournalEntries(loan, transactionId, transactionDate, principalAmount,
interestAmount, feesAmount, penaltiesAmount, overPaymentAmount, !isReversalOrder);
// Moving money from asset transfer account
journalEntryList.addAll(createJournalEntries(loan, transactionId, transactionDate, principalAmount, interestAmount, feesAmount,
penaltiesAmount, overPaymentAmount, isReversalOrder));
@@ -171,82 +175,91 @@ public class AccountingServiceImpl implements AccountingService {
});
}
private List<JournalEntry> createJournalEntries(Loan loan, Long transactionId, LocalDate transactionDate, BigDecimal principalAmount,
BigDecimal interestAmount, BigDecimal feesAmount, BigDecimal penaltiesAmount, BigDecimal overPaymentAmount,
boolean isReversalOrder) {
Long loanProductId = loan.productId();
Long loanId = loan.getId();
Office office = loan.getOffice();
String currencyCode = loan.getCurrencyCode();
List<JournalEntry> journalEntryList = new ArrayList<>();
private List<JournalEntry> createJournalEntries(final Loan loan, final Long transactionId, final LocalDate transactionDate,
final BigDecimal principalAmount, final BigDecimal interestAmount, final BigDecimal feesAmount,
final BigDecimal penaltiesAmount, final BigDecimal overPaymentAmount, final boolean isReversalOrder) {
final Long loanProductId = loan.productId();
final Long loanId = loan.getId();
final Office office = loan.getOffice();
final String currencyCode = loan.getCurrencyCode();
final List<JournalEntry> journalEntryList = new ArrayList<>();
BigDecimal totalDebitAmount = BigDecimal.ZERO;
Map<GLAccount, BigDecimal> accountMap = new LinkedHashMap<>();
final Map<GLAccount, BigDecimal> accountMap = new LinkedHashMap<>();
// principal entry
if (principalAmount != null && principalAmount.compareTo(BigDecimal.ZERO) > 0) {
AccountingConstants.AccrualAccountsForLoan accrualAccount = AccountingConstants.AccrualAccountsForLoan.LOAN_PORTFOLIO;
if (loan.isChargedOff()) {
if (loan.isFraud()) {
accrualAccount = AccountingConstants.AccrualAccountsForLoan.CHARGE_OFF_FRAUD_EXPENSE;
} else {
accrualAccount = AccountingConstants.AccrualAccountsForLoan.CHARGE_OFF_EXPENSE;
}
}
if (MathUtil.isGreaterThanZero(principalAmount)) {
totalDebitAmount = totalDebitAmount.add(principalAmount);
GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
GLAccount account;
if (loan.isChargedOff()) {
final Long chargeOffReasonId = loan.fetchChargeOffReasonId();
final ProductToGLAccountMapping mapping = chargeOffReasonId != null
? helper.getChargeOffMappingByCodeValue(loanProductId, PortfolioProductType.LOAN, chargeOffReasonId)
: null;
if (mapping != null) {
account = mapping.getGlAccount();
} else {
final AccountingConstants.AccrualAccountsForLoan accrualAccount = loan.isFraud()
? AccountingConstants.AccrualAccountsForLoan.CHARGE_OFF_FRAUD_EXPENSE
: AccountingConstants.AccrualAccountsForLoan.CHARGE_OFF_EXPENSE;
account = helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
}
} else {
account = helper.getLinkedGLAccountForLoanProduct(loanProductId,
AccountingConstants.AccrualAccountsForLoan.LOAN_PORTFOLIO.getValue());
}
accountMap.put(account, principalAmount);
}
// interest entry
if (interestAmount != null && interestAmount.compareTo(BigDecimal.ZERO) > 0) {
if (MathUtil.isGreaterThanZero(interestAmount)) {
AccountingConstants.AccrualAccountsForLoan accrualAccount = AccountingConstants.AccrualAccountsForLoan.INTEREST_RECEIVABLE;
if (loan.isChargedOff()) {
accrualAccount = AccountingConstants.AccrualAccountsForLoan.INCOME_FROM_CHARGE_OFF_INTEREST;
}
totalDebitAmount = totalDebitAmount.add(interestAmount);
GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
final GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
if (accountMap.containsKey(account)) {
BigDecimal amount = accountMap.get(account).add(interestAmount);
final BigDecimal amount = accountMap.get(account).add(interestAmount);
accountMap.put(account, amount);
} else {
accountMap.put(account, interestAmount);
}
}
// fee entry
if (feesAmount != null && feesAmount.compareTo(BigDecimal.ZERO) > 0) {
if (MathUtil.isGreaterThanZero(feesAmount)) {
AccountingConstants.AccrualAccountsForLoan accrualAccount = AccountingConstants.AccrualAccountsForLoan.FEES_RECEIVABLE;
if (loan.isChargedOff()) {
accrualAccount = AccountingConstants.AccrualAccountsForLoan.INCOME_FROM_CHARGE_OFF_FEES;
}
totalDebitAmount = totalDebitAmount.add(feesAmount);
GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
final GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
if (accountMap.containsKey(account)) {
BigDecimal amount = accountMap.get(account).add(feesAmount);
final BigDecimal amount = accountMap.get(account).add(feesAmount);
accountMap.put(account, amount);
} else {
accountMap.put(account, feesAmount);
}
}
// penalty entry
if (penaltiesAmount != null && penaltiesAmount.compareTo(BigDecimal.ZERO) > 0) {
if (MathUtil.isGreaterThanZero(penaltiesAmount)) {
AccountingConstants.AccrualAccountsForLoan accrualAccount = AccountingConstants.AccrualAccountsForLoan.PENALTIES_RECEIVABLE;
if (loan.isChargedOff()) {
accrualAccount = AccountingConstants.AccrualAccountsForLoan.INCOME_FROM_CHARGE_OFF_PENALTY;
}
totalDebitAmount = totalDebitAmount.add(penaltiesAmount);
GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
final GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, accrualAccount.getValue());
if (accountMap.containsKey(account)) {
BigDecimal amount = accountMap.get(account).add(penaltiesAmount);
final BigDecimal amount = accountMap.get(account).add(penaltiesAmount);
accountMap.put(account, amount);
} else {
accountMap.put(account, penaltiesAmount);
}
}
// overpaid entry
if (overPaymentAmount != null && overPaymentAmount.compareTo(BigDecimal.ZERO) > 0) {
if (MathUtil.isGreaterThanZero(overPaymentAmount)) {
totalDebitAmount = totalDebitAmount.add(overPaymentAmount);
GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId,
final GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId,
AccountingConstants.AccrualAccountsForLoan.OVERPAYMENT.getValue());
if (accountMap.containsKey(account)) {
BigDecimal amount = accountMap.get(account).add(overPaymentAmount);
final BigDecimal amount = accountMap.get(account).add(overPaymentAmount);
accountMap.put(account, amount);
} else {
accountMap.put(account, overPaymentAmount);
@@ -257,7 +270,7 @@ public class AccountingServiceImpl implements AccountingService {
journalEntryList.add(this.helper.createCreditJournalEntryOrReversalForInvestor(office, currencyCode, loanId, transactionId,
transactionDate, entry.getValue(), isReversalOrder, entry.getKey()));
}
if (totalDebitAmount.compareTo(BigDecimal.ZERO) > 0) {
if (MathUtil.isGreaterThanZero(totalDebitAmount)) {
journalEntryList.add(this.helper.createDebitJournalEntryOrReversalForInvestor(office, currencyCode,
AccountingConstants.FinancialActivity.ASSET_TRANSFER.getValue(), loanProductId, loanId, transactionId, transactionDate,
totalDebitAmount, isReversalOrder));