From f27c1ccf40187267f0fd3321831ba3e8273b0a17 Mon Sep 17 00:00:00 2001 From: Ruchi Dhamankar Date: Thu, 5 Oct 2023 18:18:13 +0530 Subject: [PATCH] FINERACT-1968-Advanced-Payment-Allocation-Charge-Off --- ...edPaymentScheduleTransactionProcessor.java | 21 + ...eOffWithAdvancedPaymentAllocationTest.java | 759 ++++++++++++++++++ 2 files changed, 780 insertions(+) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountChargeOffWithAdvancedPaymentAllocationTest.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 6a9c9d8a5bc..d51a8fb2f11 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -156,6 +156,7 @@ public void processLatestTransaction(LoanTransaction loanTransaction, MonetaryCu case REPAYMENT, MERCHANT_ISSUED_REFUND, PAYOUT_REFUND, GOODWILL_CREDIT, CHARGE_REFUND, CHARGE_ADJUSTMENT, DOWN_PAYMENT, WAIVE_INTEREST, RECOVERY_REPAYMENT -> handleRepayment(loanTransaction, currency, installments, charges); + case CHARGE_OFF -> handleChargeOff(loanTransaction, currency, installments); // TODO: Cover rest of the transaction types default -> { log.warn("Unhandled transaction processing for transaction type: {}", loanTransaction.getTypeOf()); @@ -363,6 +364,26 @@ private void handleOverpayment(Money overpaymentPortion, LoanTransaction loanTra } } + private void handleChargeOff(LoanTransaction loanTransaction, MonetaryCurrency currency, + List installments) { + loanTransaction.resetDerivedComponents(); + // determine how much is outstanding total and breakdown for principal, interest and charges + Money principalPortion = Money.zero(currency); + Money interestPortion = Money.zero(currency); + Money feeChargesPortion = Money.zero(currency); + Money penaltychargesPortion = Money.zero(currency); + for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { + if (currentInstallment.isNotFullyPaidOff()) { + principalPortion = principalPortion.plus(currentInstallment.getPrincipalOutstanding(currency)); + interestPortion = interestPortion.plus(currentInstallment.getInterestOutstanding(currency)); + feeChargesPortion = feeChargesPortion.plus(currentInstallment.getFeeChargesOutstanding(currency)); + penaltychargesPortion = penaltychargesPortion.plus(currentInstallment.getPenaltyChargesCharged(currency)); + } + } + + loanTransaction.updateComponentsAndTotal(principalPortion, interestPortion, feeChargesPortion, penaltychargesPortion); + } + @AllArgsConstructor @Getter @Setter diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountChargeOffWithAdvancedPaymentAllocationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountChargeOffWithAdvancedPaymentAllocationTest.java new file mode 100644 index 00000000000..e6d32ab67fb --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanAccountChargeOffWithAdvancedPaymentAllocationTest.java @@ -0,0 +1,759 @@ +/** + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.fineract.client.models.AdvancedPaymentData; +import org.apache.fineract.client.models.AllowAttributeOverrides; +import org.apache.fineract.client.models.ChargeData; +import org.apache.fineract.client.models.ChargeToGLAccountMapper; +import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse; +import org.apache.fineract.client.models.GetLoanFeeToIncomeAccountMappings; +import org.apache.fineract.client.models.GetLoanPaymentChannelToFundSourceMappings; +import org.apache.fineract.client.models.GetLoanTransactionRelation; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTransactionIdResponse; +import org.apache.fineract.client.models.JournalEntryTransactionItem; +import org.apache.fineract.client.models.PaymentAllocationOrder; +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.PostLoansLoanIdTransactionsResponse; +import org.apache.fineract.client.models.PostPaymentTypesRequest; +import org.apache.fineract.client.models.PostPaymentTypesResponse; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.PaymentTypeHelper; +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.JournalEntryHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.apache.fineract.integrationtests.common.funds.FundsHelper; +import org.apache.fineract.integrationtests.common.funds.FundsResourceHandler; +import org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanProductHelper; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper; +import org.apache.fineract.integrationtests.common.system.CodeHelper; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(LoanTestLifecycleExtension.class) +public class LoanAccountChargeOffWithAdvancedPaymentAllocationTest { + + private static final DateTimeFormatter DATE_FORMATTER = new DateTimeFormatterBuilder().appendPattern("dd MMMM yyyy").toFormatter(); + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + private ClientHelper clientHelper; + private LoanTransactionHelper loanTransactionHelper; + private JournalEntryHelper journalEntryHelper; + private AccountHelper accountHelper; + private LoanProductHelper loanProductHelper; + private PaymentTypeHelper paymentTypeHelper; + // asset + private Account loansReceivable; + private Account interestFeeReceivable; + private Account suspenseAccount; + private Account fundReceivables; + // liability + private Account suspenseClearingAccount; + private Account overpaymentAccount; + // income + private Account interestIncome; + private Account feeIncome; + private Account feeChargeOff; + private Account recoveries; + private Account interestIncomeChargeOff; + // expense + private Account creditLossBadDebt; + private Account creditLossBadDebtFraud; + private Account writtenOff; + private Account goodwillExpenseAccount; + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec); + this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); + this.loanProductHelper = new LoanProductHelper(); + this.paymentTypeHelper = new PaymentTypeHelper(); + + // Asset + this.loansReceivable = this.accountHelper.createAssetAccount(); + this.interestFeeReceivable = this.accountHelper.createAssetAccount(); + this.suspenseAccount = this.accountHelper.createAssetAccount(); + this.fundReceivables = this.accountHelper.createAssetAccount(); + + // Liability + this.suspenseClearingAccount = this.accountHelper.createLiabilityAccount(); + this.overpaymentAccount = this.accountHelper.createLiabilityAccount(); + + // income + this.interestIncome = this.accountHelper.createIncomeAccount(); + this.feeIncome = this.accountHelper.createIncomeAccount(); + this.feeChargeOff = this.accountHelper.createIncomeAccount(); + this.recoveries = this.accountHelper.createIncomeAccount(); + this.interestIncomeChargeOff = this.accountHelper.createIncomeAccount(); + + // expense + this.creditLossBadDebt = this.accountHelper.createExpenseAccount(); + this.creditLossBadDebtFraud = this.accountHelper.createExpenseAccount(); + this.writtenOff = this.accountHelper.createExpenseAccount(); + this.goodwillExpenseAccount = this.accountHelper.createExpenseAccount(); + + this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec); + this.clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); + } + + // Charge-off accounting and balances + @Test + public void loanChargeOffWithAdvancedPaymentStrategyTest() { + String loanExternalIdStr = UUID.randomUUID().toString(); + final Integer loanProductID = createLoanProductWithPeriodicAccrualAccountingAndAdvancedPaymentAllocationStrategy(); + final Integer clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue(); + final Integer loanId = createLoanAccount(clientId, loanProductID, loanExternalIdStr); + + // apply charges + Integer feeCharge = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "10", false)); + + LocalDate targetDate = LocalDate.of(2022, 9, 5); + final String feeCharge1AddedDate = DATE_FORMATTER.format(targetDate); + Integer feeLoanChargeId = loanTransactionHelper.addChargesForLoan(loanId, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(feeCharge), feeCharge1AddedDate, "10")); + + // apply penalty + Integer penalty = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "10", true)); + + final String penaltyCharge1AddedDate = DATE_FORMATTER.format(targetDate); + + Integer penalty1LoanChargeId = this.loanTransactionHelper.addChargesForLoan(loanId, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(penalty), penaltyCharge1AddedDate, "10")); + + // make Repayment + final PostLoansLoanIdTransactionsResponse repaymentTransaction = loanTransactionHelper.makeLoanRepayment(loanExternalIdStr, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("9 September 2022").locale("en") + .transactionAmount(10.0)); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + + // set loan as chargeoff + String randomText = Utils.randomStringGenerator("en", 5) + Utils.randomNumberGenerator(6) + Utils.randomStringGenerator("is", 5); + Integer chargeOffReasonId = CodeHelper.createChargeOffCodeValue(requestSpec, responseSpec, randomText, 1); + String transactionExternalId = UUID.randomUUID().toString(); + PostLoansLoanIdTransactionsResponse chargeOffTransaction = this.loanTransactionHelper.chargeOffLoan((long) loanId, + new PostLoansLoanIdTransactionsRequest().transactionDate("10 September 2022").locale("en").dateFormat("dd MMMM yyyy") + .externalId(transactionExternalId).chargeOffReasonId((long) chargeOffReasonId)); + + loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + // verify amounts for charge-off transaction + verifyTransaction(LocalDate.of(2022, 9, 10), 1010.0f, 1000.0f, 0.0f, 10.0f, 0.0f, loanId, "chargeoff"); + // verify journal entries + GetJournalEntriesTransactionIdResponse journalEntriesForChargeOff = journalEntryHelper + .getJournalEntries("L" + chargeOffTransaction.getResourceId().toString()); + + assertNotNull(journalEntriesForChargeOff); + + List journalEntries = journalEntriesForChargeOff.getPageItems(); + assertEquals(4, journalEntries.size()); + verifyJournalEntry(journalEntries.get(3), 1000.0, LocalDate.of(2022, 9, 10), loansReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(2), 10.0, LocalDate.of(2022, 9, 10), interestFeeReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(1), 1000.0, LocalDate.of(2022, 9, 10), creditLossBadDebt, "DEBIT"); + verifyJournalEntry(journalEntries.get(0), 10.0, LocalDate.of(2022, 9, 10), feeChargeOff, "DEBIT"); + + } + + // Reverse Replay of Charge-Off + @Test + public void loanChargeOffReverseReplayWithAdvancedPaymentStrategyTest() { + String loanExternalIdStr = UUID.randomUUID().toString(); + final Integer loanProductID = createLoanProductWithPeriodicAccrualAccountingAndAdvancedPaymentAllocationStrategy(); + final Integer clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue(); + final Integer loanId = createLoanAccount(clientId, loanProductID, loanExternalIdStr); + + // apply charges + Integer feeCharge = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "10", false)); + + LocalDate targetDate = LocalDate.of(2022, 9, 5); + final String feeCharge1AddedDate = DATE_FORMATTER.format(targetDate); + Integer feeLoanChargeId = loanTransactionHelper.addChargesForLoan(loanId, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(feeCharge), feeCharge1AddedDate, "10")); + + // apply penalty + Integer penalty = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "10", true)); + + final String penaltyCharge1AddedDate = DATE_FORMATTER.format(targetDate); + + Integer penalty1LoanChargeId = this.loanTransactionHelper.addChargesForLoan(loanId, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(penalty), penaltyCharge1AddedDate, "10")); + + // make Repayment + final PostLoansLoanIdTransactionsResponse repaymentTransaction = loanTransactionHelper.makeLoanRepayment(loanExternalIdStr, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("9 September 2022").locale("en") + .transactionAmount(10.0)); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + + // set loan as chargeoff + String randomText = Utils.randomStringGenerator("en", 5) + Utils.randomNumberGenerator(6) + Utils.randomStringGenerator("is", 5); + Integer chargeOffReasonId = CodeHelper.createChargeOffCodeValue(requestSpec, responseSpec, randomText, 1); + String transactionExternalId = UUID.randomUUID().toString(); + PostLoansLoanIdTransactionsResponse chargeOffTransaction = this.loanTransactionHelper.chargeOffLoan((long) loanId, + new PostLoansLoanIdTransactionsRequest().transactionDate("10 September 2022").locale("en").dateFormat("dd MMMM yyyy") + .externalId(transactionExternalId).chargeOffReasonId((long) chargeOffReasonId)); + + loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + // verify amounts for charge-off transaction + verifyTransaction(LocalDate.of(2022, 9, 10), 1010.0f, 1000.0f, 0.0f, 10.0f, 0.0f, loanId, "chargeoff"); + + Long reversedAndReplayedTransactionId = chargeOffTransaction.getResourceId(); + + // reverse Repayment + loanTransactionHelper.reverseRepayment(loanId, repaymentTransaction.getResourceId().intValue(), "11 September 2022"); + + // verify chargeOffTransaction gets reverse replayed + + GetLoansLoanIdTransactionsTransactionIdResponse getLoansTransactionResponse = loanTransactionHelper + .getLoanTransactionDetails((long) loanId, transactionExternalId); + assertNotNull(getLoansTransactionResponse); + assertNotNull(getLoansTransactionResponse.getTransactionRelations()); + + // test replayed relationship + GetLoanTransactionRelation transactionRelation = getLoansTransactionResponse.getTransactionRelations().iterator().next(); + assertEquals(reversedAndReplayedTransactionId, transactionRelation.getToLoanTransaction()); + assertEquals("REPLAYED", transactionRelation.getRelationType()); + + // verify amounts for charge-off transaction + verifyTransaction(LocalDate.of(2022, 9, 10), 1020.0f, 1000.0f, 0.0f, 10.0f, 10.0f, loanId, "chargeoff"); + + } + + // undo Charge-Off + @Test + public void loanUndoChargeOffTest() { + // Loan ExternalId + String loanExternalIdStr = UUID.randomUUID().toString(); + + final Integer loanProductID = createLoanProductWithPeriodicAccrualAccountingAndAdvancedPaymentAllocationStrategy(); + final Integer clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue(); + final Integer loanId = createLoanAccount(clientId, loanProductID, loanExternalIdStr); + + // make Repayment + final PostLoansLoanIdTransactionsResponse repaymentTransaction = loanTransactionHelper.makeLoanRepayment(loanExternalIdStr, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("6 September 2022").locale("en") + .transactionAmount(100.0)); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + + // set loan as chargeoff + String randomText = Utils.randomStringGenerator("en", 5) + Utils.randomNumberGenerator(6) + Utils.randomStringGenerator("is", 5); + Integer chargeOffReasonId = CodeHelper.createChargeOffCodeValue(requestSpec, responseSpec, randomText, 1); + String transactionExternalId = UUID.randomUUID().toString(); + loanTransactionHelper.chargeOffLoan((long) loanId, new PostLoansLoanIdTransactionsRequest().transactionDate("7 September 2022") + .locale("en").dateFormat("dd MMMM yyyy").externalId(transactionExternalId).chargeOffReasonId((long) chargeOffReasonId)); + + loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + // undo charge-off + String reverseTransactionExternalId = UUID.randomUUID().toString(); + PostLoansLoanIdTransactionsResponse undoChargeOffTxResponse = loanTransactionHelper.undoChargeOffLoan((long) loanId, + new PostLoansLoanIdTransactionsRequest().reversalExternalId(reverseTransactionExternalId)); + assertNotNull(undoChargeOffTxResponse); + + loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertFalse(loanDetails.getChargedOff()); + + GetLoansLoanIdTransactionsTransactionIdResponse chargeOffTransactionDetails = loanTransactionHelper + .getLoanTransactionDetails((long) loanId, transactionExternalId); + assertNotNull(chargeOffTransactionDetails); + assertTrue(chargeOffTransactionDetails.getManuallyReversed()); + assertEquals(reverseTransactionExternalId, chargeOffTransactionDetails.getReversalExternalId()); + } + + // Backdated repayment transaction, Reverse replay of charge off + @Test + public void postChargeOffAddBackdatedTransactionAndReverseReplayTest() { + String loanExternalIdStr = UUID.randomUUID().toString(); + final Integer loanProductID = createLoanProductWithPeriodicAccrualAccountingAndAdvancedPaymentAllocationStrategy(); + final Integer clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue(); + final Integer loanId = createLoanAccount(clientId, loanProductID, loanExternalIdStr); + + // apply charges + Integer feeCharge = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "10", false)); + + LocalDate targetDate = LocalDate.of(2022, 9, 5); + final String feeCharge1AddedDate = DATE_FORMATTER.format(targetDate); + Integer feeLoanChargeId = loanTransactionHelper.addChargesForLoan(loanId, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(feeCharge), feeCharge1AddedDate, "10")); + + // set loan as chargeoff + String randomText = Utils.randomStringGenerator("en", 5) + Utils.randomNumberGenerator(6) + Utils.randomStringGenerator("is", 5); + Integer chargeOffReasonId = CodeHelper.createChargeOffCodeValue(requestSpec, responseSpec, randomText, 1); + String transactionExternalId = UUID.randomUUID().toString(); + PostLoansLoanIdTransactionsResponse chargeOffTransaction = loanTransactionHelper.chargeOffLoan((long) loanId, + new PostLoansLoanIdTransactionsRequest().transactionDate("14 September 2022").locale("en").dateFormat("dd MMMM yyyy") + .externalId(transactionExternalId).chargeOffReasonId((long) chargeOffReasonId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + Long reversedAndReplayedTransactionId = chargeOffTransaction.getResourceId(); + + // verify Journal Entries For ChargeOff Transaction + GetJournalEntriesTransactionIdResponse journalEntriesForChargeOff = journalEntryHelper + .getJournalEntries("L" + chargeOffTransaction.getResourceId().toString()); + + assertNotNull(journalEntriesForChargeOff); + List journalEntries = journalEntriesForChargeOff.getPageItems(); + assertEquals(4, journalEntries.size()); + + verifyJournalEntry(journalEntries.get(3), 1000.0, LocalDate.of(2022, 9, 14), loansReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(2), 10.0, LocalDate.of(2022, 9, 14), interestFeeReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(1), 1000.0, LocalDate.of(2022, 9, 14), creditLossBadDebt, "DEBIT"); + verifyJournalEntry(journalEntries.get(0), 10.0, LocalDate.of(2022, 9, 14), feeChargeOff, "DEBIT"); + + // make Repayment before chargeoff date + final PostLoansLoanIdTransactionsResponse repaymentTransaction = loanTransactionHelper.makeLoanRepayment(loanExternalIdStr, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("7 September 2022").locale("en") + .transactionAmount(100.0)); + + loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + // verify Journal Entries for Repayment transaction + + GetJournalEntriesTransactionIdResponse journalEntriesForRepayment = journalEntryHelper + .getJournalEntries("L" + repaymentTransaction.getResourceId().toString()); + assertNotNull(journalEntriesForRepayment); + + journalEntries = journalEntriesForRepayment.getPageItems(); + assertEquals(3, journalEntries.size()); + + verifyJournalEntry(journalEntries.get(2), 90.0, LocalDate.of(2022, 9, 7), loansReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(1), 10.0, LocalDate.of(2022, 9, 7), interestFeeReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(0), 100.0, LocalDate.of(2022, 9, 7), suspenseClearingAccount, "DEBIT"); + + // verify reverse replay of Charge-Off + + GetLoansLoanIdTransactionsTransactionIdResponse getLoansTransactionResponse = loanTransactionHelper + .getLoanTransactionDetails((long) loanId, transactionExternalId); + assertNotNull(getLoansTransactionResponse); + assertNotNull(getLoansTransactionResponse.getTransactionRelations()); + + // test replayed relationship + GetLoanTransactionRelation transactionRelation = getLoansTransactionResponse.getTransactionRelations().iterator().next(); + assertEquals(reversedAndReplayedTransactionId, transactionRelation.getToLoanTransaction()); + assertEquals("REPLAYED", transactionRelation.getRelationType()); + + // verify amounts for charge-off transaction + verifyTransaction(LocalDate.of(2022, 9, 14), 910.0f, 910.0f, 0.0f, 0.0f, 0.0f, loanId, "chargeoff"); + + // make Repayment after chargeoff date + final PostLoansLoanIdTransactionsResponse repaymentTransaction_1 = loanTransactionHelper.makeLoanRepayment(loanExternalIdStr, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("15 September 2022").locale("en") + .transactionAmount(100.0)); + + loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + // verify Journal Entries for Repayment transaction + journalEntriesForRepayment = journalEntryHelper.getJournalEntries("L" + repaymentTransaction_1.getResourceId().toString()); + + assertNotNull(journalEntriesForRepayment); + + journalEntries = journalEntriesForRepayment.getPageItems(); + assertEquals(2, journalEntries.size()); + + verifyJournalEntry(journalEntries.get(1), 100.0, LocalDate.of(2022, 9, 15), recoveries, "CREDIT"); + verifyJournalEntry(journalEntries.get(0), 100.0, LocalDate.of(2022, 9, 15), suspenseClearingAccount, "DEBIT"); + } + + // Repayment before charge off on charge off date, reverse replay of charge off + @Test + public void transactionOnChargeOffDateReverseTest() { + String loanExternalIdStr = UUID.randomUUID().toString(); + final Integer loanProductID = createLoanProductWithPeriodicAccrualAccountingAndAdvancedPaymentAllocationStrategy(); + final Integer clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue(); + final Integer loanId = createLoanAccount(clientId, loanProductID, loanExternalIdStr); + + // apply charges + Integer feeCharge = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "10", false)); + + LocalDate targetDate = LocalDate.of(2022, 9, 5); + final String feeCharge1AddedDate = DATE_FORMATTER.format(targetDate); + Integer feeLoanChargeId = loanTransactionHelper.addChargesForLoan(loanId, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(feeCharge), feeCharge1AddedDate, "10")); + + // make Repayment before charge-off on charge off date + final PostLoansLoanIdTransactionsResponse repaymentTransaction = loanTransactionHelper.makeLoanRepayment(loanExternalIdStr, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate("7 September 2022").locale("en") + .transactionAmount(100.0)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + + // verify Journal Entries for Repayment transaction + GetJournalEntriesTransactionIdResponse journalEntriesForRepayment = journalEntryHelper + .getJournalEntries("L" + repaymentTransaction.getResourceId().toString()); + + assertNotNull(journalEntriesForRepayment); + + List journalEntries = journalEntriesForRepayment.getPageItems(); + assertEquals(3, journalEntries.size()); + + verifyJournalEntry(journalEntries.get(2), 90.0, LocalDate.of(2022, 9, 7), loansReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(1), 10.0, LocalDate.of(2022, 9, 7), interestFeeReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(0), 100.0, LocalDate.of(2022, 9, 7), suspenseClearingAccount, "DEBIT"); + + // set loan as chargeoff + String randomText = Utils.randomStringGenerator("en", 5) + Utils.randomNumberGenerator(6) + Utils.randomStringGenerator("is", 5); + Integer chargeOffReasonId = CodeHelper.createChargeOffCodeValue(requestSpec, responseSpec, randomText, 1); + String transactionExternalId = UUID.randomUUID().toString(); + PostLoansLoanIdTransactionsResponse chargeOffTransaction = loanTransactionHelper.chargeOffLoan((long) loanId, + new PostLoansLoanIdTransactionsRequest().transactionDate("7 September 2022").locale("en").dateFormat("dd MMMM yyyy") + .externalId(transactionExternalId).chargeOffReasonId((long) chargeOffReasonId)); + + loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + Long reversedAndReplayedTransactionId = chargeOffTransaction.getResourceId(); + + // verify Journal Entries For ChargeOff Transaction + GetJournalEntriesTransactionIdResponse journalEntriesForChargeOff = journalEntryHelper + .getJournalEntries("L" + chargeOffTransaction.getResourceId().toString()); + + assertNotNull(journalEntriesForChargeOff); + journalEntries = journalEntriesForChargeOff.getPageItems(); + assertEquals(2, journalEntries.size()); + + verifyJournalEntry(journalEntries.get(1), 910.0, LocalDate.of(2022, 9, 7), loansReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(0), 910.0, LocalDate.of(2022, 9, 7), creditLossBadDebt, "DEBIT"); + + // reverse Repayment + loanTransactionHelper.reverseRepayment(loanId, repaymentTransaction.getResourceId().intValue(), "7 September 2022"); + loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetails.getStatus().getActive()); + assertTrue(loanDetails.getChargedOff()); + + // verify Journal Entries for Reversed Repayment transaction + journalEntriesForRepayment = journalEntryHelper.getJournalEntries("L" + repaymentTransaction.getResourceId().toString()); + assertNotNull(journalEntriesForRepayment); + + journalEntries = journalEntriesForRepayment.getPageItems(); + assertEquals(6, journalEntries.size()); + + verifyJournalEntry(journalEntries.get(5), 90.0, LocalDate.of(2022, 9, 7), loansReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(4), 10.0, LocalDate.of(2022, 9, 7), interestFeeReceivable, "CREDIT"); + verifyJournalEntry(journalEntries.get(3), 100.0, LocalDate.of(2022, 9, 7), suspenseClearingAccount, "DEBIT"); + verifyJournalEntry(journalEntries.get(2), 90.0, LocalDate.of(2022, 9, 7), loansReceivable, "DEBIT"); + verifyJournalEntry(journalEntries.get(1), 10.0, LocalDate.of(2022, 9, 7), interestFeeReceivable, "DEBIT"); + verifyJournalEntry(journalEntries.get(0), 100.0, LocalDate.of(2022, 9, 7), suspenseClearingAccount, "CREDIT"); + + // verify reverse replay of Charge-Off + + GetLoansLoanIdTransactionsTransactionIdResponse getLoansTransactionResponse = loanTransactionHelper + .getLoanTransactionDetails((long) loanId, transactionExternalId); + assertNotNull(getLoansTransactionResponse); + assertNotNull(getLoansTransactionResponse.getTransactionRelations()); + + // test replayed relationship + GetLoanTransactionRelation transactionRelation = getLoansTransactionResponse.getTransactionRelations().iterator().next(); + assertEquals(reversedAndReplayedTransactionId, transactionRelation.getToLoanTransaction()); + assertEquals("REPLAYED", transactionRelation.getRelationType()); + + // verify amounts for charge-off transaction + verifyTransaction(LocalDate.of(2022, 9, 7), 1010.0f, 1000.0f, 0.0f, 10.0f, 0.0f, loanId, "chargeoff"); + + } + + private void verifyJournalEntry(JournalEntryTransactionItem journalEntryTransactionItem, Double amount, LocalDate entryDate, + Account account, String type) { + assertEquals(amount, journalEntryTransactionItem.getAmount()); + assertEquals(entryDate, journalEntryTransactionItem.getTransactionDate()); + assertEquals(account.getAccountID().longValue(), journalEntryTransactionItem.getGlAccountId().longValue()); + assertEquals(type, journalEntryTransactionItem.getEntryType().getValue()); + } + + private void verifyTransaction(final LocalDate transactionDate, final Float transactionAmount, final Float principalPortion, + final Float interestPortion, final Float feePortion, final Float penaltyPortion, final Integer loanID, + final String transactionOfType) { + ArrayList transactions = (ArrayList) loanTransactionHelper.getLoanTransactions(this.requestSpec, + this.responseSpec, loanID); + boolean isTransactionFound = false; + for (int i = 0; i < transactions.size(); i++) { + HashMap transactionType = (HashMap) transactions.get(i).get("type"); + boolean isTransaction = (Boolean) transactionType.get(transactionOfType); + + if (isTransaction) { + ArrayList transactionDateAsArray = (ArrayList) transactions.get(i).get("date"); + LocalDate transactionEntryDate = LocalDate.of(transactionDateAsArray.get(0), transactionDateAsArray.get(1), + transactionDateAsArray.get(2)); + + if (transactionDate.isEqual(transactionEntryDate)) { + isTransactionFound = true; + assertEquals(transactionAmount, Float.valueOf(String.valueOf(transactions.get(i).get("amount"))), + "Mismatch in transaction amounts"); + assertEquals(principalPortion, Float.valueOf(String.valueOf(transactions.get(i).get("principalPortion"))), + "Mismatch in transaction amounts"); + assertEquals(interestPortion, Float.valueOf(String.valueOf(transactions.get(i).get("interestPortion"))), + "Mismatch in transaction amounts"); + assertEquals(feePortion, Float.valueOf(String.valueOf(transactions.get(i).get("feeChargesPortion"))), + "Mismatch in transaction amounts"); + assertEquals(penaltyPortion, Float.valueOf(String.valueOf(transactions.get(i).get("penaltyChargesPortion"))), + "Mismatch in transaction amounts"); + break; + } + } + } + assertTrue(isTransactionFound, "No Transaction entries are posted"); + } + + private Integer createLoanAccount(final Integer clientID, final Integer loanProductID, final String externalId) { + + String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("1000").withLoanTermFrequency("30") + .withLoanTermFrequencyAsDays().withNumberOfRepayments("1").withRepaymentEveryAfter("30").withRepaymentFrequencyTypeAsDays() + .withInterestRatePerPeriod("0").withInterestTypeAsFlatBalance().withAmortizationTypeAsEqualPrincipalPayments() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withExpectedDisbursementDate("03 September 2022") + .withSubmittedOnDate("01 September 2022").withLoanType("individual").withExternalId(externalId) + .withRepaymentStrategy("advanced-payment-allocation-strategy").build(clientID.toString(), loanProductID.toString(), null); + + final Integer loanId = loanTransactionHelper.getLoanId(loanApplicationJSON); + loanTransactionHelper.approveLoan("02 September 2022", "1000", loanId, null); + loanTransactionHelper.disburseLoanWithTransactionAmount("03 September 2022", loanId, "1000"); + return loanId; + } + + private Integer createLoanProductWithPeriodicAccrualAccountingAndAdvancedPaymentAllocationStrategy() { + + String name = Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_", 6); + String shortName = Utils.uniqueRandomStringGenerator("", 4); + + List principalVariationsForBorrowerCycle = new ArrayList<>(); + List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); + List interestRateVariationsForBorrowerCycle = new ArrayList<>(); + List charges = new ArrayList<>(); + List penaltyToIncomeAccountMappings = new ArrayList<>(); + List feeToIncomeAccountMappings = new ArrayList<>(); + + String paymentTypeName = PaymentTypeHelper.randomNameGenerator("P_T", 5); + String description = PaymentTypeHelper.randomNameGenerator("PT_Desc", 15); + Boolean isCashPayment = false; + Integer position = 1; + + PostPaymentTypesResponse paymentTypesResponse = paymentTypeHelper.createPaymentType(new PostPaymentTypesRequest() + .name(paymentTypeName).description(description).isCashPayment(isCashPayment).position(position)); + Long paymentTypeIdOne = paymentTypesResponse.getResourceId(); + Assertions.assertNotNull(paymentTypeIdOne); + + List paymentChannelToFundSourceMappings = new ArrayList<>(); + GetLoanPaymentChannelToFundSourceMappings loanPaymentChannelToFundSourceMappings = new GetLoanPaymentChannelToFundSourceMappings(); + loanPaymentChannelToFundSourceMappings.fundSourceAccountId(fundReceivables.getAccountID().longValue()); + loanPaymentChannelToFundSourceMappings.paymentTypeId(paymentTypeIdOne.longValue()); + paymentChannelToFundSourceMappings.add(loanPaymentChannelToFundSourceMappings); + + // fund + FundsHelper fh = FundsHelper.create(Utils.uniqueRandomStringGenerator("", 10)).externalId(UUID.randomUUID().toString()).build(); + String jsonData = fh.toJSON(); + + final Long fundID = createFund(jsonData, this.requestSpec, this.responseSpec); + Assertions.assertNotNull(fundID); + + // Delinquency Bucket + final Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec); + + String futureInstallmentAllocationRule = "NEXT_INSTALLMENT"; + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(futureInstallmentAllocationRule); + + PostLoanProductsRequest loanProductsRequest = new PostLoanProductsRequest().name(name)// + .shortName(shortName)// + .description("Loan Product Description")// + .fundId(fundID)// + .startDate(null)// + .closeDate(null)// + .includeInBorrowerCycle(false)// + .currencyCode("USD")// + .digitsAfterDecimal(2)// + .inMultiplesOf(0)// + .installmentAmountInMultiplesOf(1)// + .useBorrowerCycle(false)// + .minPrincipal(100.0)// + .principal(1000.0)// + .maxPrincipal(10000.0)// + .minNumberOfRepayments(1)// + .numberOfRepayments(1)// + .maxNumberOfRepayments(30)// + .isLinkedToFloatingInterestRates(false)// + .minInterestRatePerPeriod((double) 0)// + .interestRatePerPeriod((double) 0)// + .maxInterestRatePerPeriod((double) 0)// + .interestRateFrequencyType(2)// + .repaymentEvery(30)// + .repaymentFrequencyType(0)// + .principalVariationsForBorrowerCycle(principalVariationsForBorrowerCycle)// + .numberOfRepaymentVariationsForBorrowerCycle(numberOfRepaymentVariationsForBorrowerCycle)// + .interestRateVariationsForBorrowerCycle(interestRateVariationsForBorrowerCycle)// + .amortizationType(1)// + .interestType(0)// + .isEqualAmortization(false)// + .interestCalculationPeriodType(1)// + .transactionProcessingStrategyCode("advanced-payment-allocation-strategy")// + .addPaymentAllocationItem(defaultAllocation)// + .daysInYearType(1)// + .daysInMonthType(1)// + .canDefineInstallmentAmount(true)// + .graceOnArrearsAgeing(3)// + .overdueDaysForNPA(179)// + .accountMovesOutOfNPAOnlyOnArrearsCompletion(false)// + .principalThresholdForLastInstallment(50)// + .allowVariableInstallments(false)// + .canUseForTopup(false)// + .isInterestRecalculationEnabled(false)// + .holdGuaranteeFunds(false)// + .multiDisburseLoan(true)// + .allowAttributeOverrides(new AllowAttributeOverrides()// + .amortizationType(true)// + .interestType(true)// + .transactionProcessingStrategyCode(true)// + .interestCalculationPeriodType(true)// + .inArrearsTolerance(true)// + .repaymentEvery(true)// + .graceOnPrincipalAndInterestPayment(true)// + .graceOnArrearsAgeing(true))// + .allowPartialPeriodInterestCalcualtion(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0)// + .charges(charges)// + .accountingRule(3)// + .fundSourceAccountId(suspenseClearingAccount.getAccountID().longValue())// + .loanPortfolioAccountId(loansReceivable.getAccountID().longValue())// + .transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())// + .interestOnLoanAccountId(interestIncome.getAccountID().longValue())// + .incomeFromFeeAccountId(feeIncome.getAccountID().longValue())// + .incomeFromPenaltyAccountId(feeIncome.getAccountID().longValue())// + .incomeFromRecoveryAccountId(recoveries.getAccountID().longValue())// + .writeOffAccountId(writtenOff.getAccountID().longValue())// + .overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())// + .receivableInterestAccountId(interestFeeReceivable.getAccountID().longValue())// + .receivableFeeAccountId(interestFeeReceivable.getAccountID().longValue())// + .receivablePenaltyAccountId(interestFeeReceivable.getAccountID().longValue())// + .dateFormat("dd MMMM yyyy")// + .locale("en_GB")// + .disallowExpectedDisbursements(true)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType("percentage")// + .overAppliedNumber(50)// + .delinquencyBucketId(delinquencyBucketId.longValue())// + .goodwillCreditAccountId(goodwillExpenseAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditInterestAccountId(interestIncomeChargeOff.getAccountID().longValue())// + .incomeFromGoodwillCreditFeesAccountId(feeChargeOff.getAccountID().longValue())// + .incomeFromGoodwillCreditPenaltyAccountId(feeChargeOff.getAccountID().longValue())// + .paymentChannelToFundSourceMappings(paymentChannelToFundSourceMappings)// + .penaltyToIncomeAccountMappings(penaltyToIncomeAccountMappings)// + .feeToIncomeAccountMappings(feeToIncomeAccountMappings)// + .incomeFromChargeOffInterestAccountId(interestIncomeChargeOff.getAccountID().longValue())// + .incomeFromChargeOffFeesAccountId(feeChargeOff.getAccountID().longValue())// + .chargeOffExpenseAccountId(creditLossBadDebt.getAccountID().longValue())// + .chargeOffFraudExpenseAccountId(creditLossBadDebtFraud.getAccountID().longValue())// + .incomeFromChargeOffPenaltyAccountId(feeChargeOff.getAccountID().longValue());// + + PostLoanProductsResponse loanProductCreateResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + return loanProductCreateResponse.getResourceId().intValue(); + } + + private Long createFund(final String fundJSON, final RequestSpecification requestSpec, final ResponseSpecification responseSpec) { + String fundId = String.valueOf(FundsResourceHandler.createFund(fundJSON, requestSpec, responseSpec)); + if (fundId.equals("null")) { + // Invalid JSON data parameters + return null; + } + + return Long.valueOf(fundId); + } + + private AdvancedPaymentData createDefaultPaymentAllocation(String futureInstallmentAllocationRule) { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("DEFAULT"); + advancedPaymentData.setFutureInstallmentAllocationRule(futureInstallmentAllocationRule); + + List paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_PRINCIPAL, PaymentAllocationType.PAST_DUE_INTEREST, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_PRINCIPAL, + PaymentAllocationType.DUE_INTEREST, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private List getPaymentAllocationOrder(PaymentAllocationType... paymentAllocationTypes) { + AtomicInteger integer = new AtomicInteger(1); + return Arrays.stream(paymentAllocationTypes).map(pat -> { + PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); + paymentAllocationOrder.setPaymentAllocationRule(pat.name()); + paymentAllocationOrder.setOrder(integer.getAndIncrement()); + return paymentAllocationOrder; + }).toList(); + } +}