From e1d043bb2d3422e3ade71db169293a04100c3b0c Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Tue, 7 Jan 2025 16:46:59 +0100 Subject: [PATCH] FINERACT-1806: Advanced accounting - Charge-off reason --- .../ProductToGLAccountMappingRepository.java | 2 +- .../portfolio/loanaccount/domain/Loan.java | 16 ++- .../accounting/journalentry/data/LoanDTO.java | 2 +- .../service/AccountingProcessorHelper.java | 4 +- ...ccrualBasedAccountingProcessorForLoan.java | 2 +- .../api/CodeValuesApiResourceSwagger.java | 2 + ...ateJournalEntriesForChargeOffLoanTest.java | 2 +- .../BaseLoanIntegrationTest.java | 1 + .../LoanChargeOffAccountingTest.java | 129 ++++++++++++++++++ .../common/system/CodeHelper.java | 17 ++- 10 files changed, 159 insertions(+), 18 deletions(-) diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java index 9a5436e6c14..4f05f8a43be 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java @@ -67,7 +67,7 @@ List findAllChargeOffReasonsMappings(@Param("productI @Param("productType") int productType); @Query("select mapping from ProductToGLAccountMapping mapping where mapping.chargeOffReason.id =:chargeOffReasonId") - ProductToGLAccountMapping findChargeOffReasonMappingById(@Param("chargeOffReasonId") Integer chargeOffReasonId); + ProductToGLAccountMapping findChargeOffReasonMappingById(@Param("chargeOffReasonId") Long chargeOffReasonId); @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId AND mapping.productType =:productType AND mapping.charge IS NULL AND mapping.paymentType IS NULL AND mapping.chargeOffReason IS NULL") List findAllRegularMappings(@Param("productId") Long productId, @Param("productType") Integer productType); 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 c6524c9d9fd..e50e6822e42 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 @@ -2190,13 +2190,9 @@ private Map buildAccountingMapForChargeOffDateCriteria(final Str boolean isBeforeChargeOffDate) { final Map accountingBridgeDataChargeOff = new LinkedHashMap<>( getAccountingBridgeDataGenericAttributes(currencyCode, isAccountTransfer)); - if (isBeforeChargeOffDate) { - accountingBridgeDataChargeOff.put("isChargeOff", false); - accountingBridgeDataChargeOff.put("isFraud", isFraud()); - } else { - accountingBridgeDataChargeOff.put("isChargeOff", isChargedOff()); - accountingBridgeDataChargeOff.put("isFraud", isFraud()); - } + accountingBridgeDataChargeOff.put("isChargeOff", !isBeforeChargeOffDate && isChargedOff()); + accountingBridgeDataChargeOff.put("isFraud", isFraud()); + accountingBridgeDataChargeOff.put("chargeOffReasonCodeValue", fetchChargeOffReasonId()); return accountingBridgeDataChargeOff; } @@ -2274,6 +2270,8 @@ public Map deriveAccountingBridgeData(final String currencyCode, accountingBridgeData.put("periodicAccrualBasedAccountingEnabled", isPeriodicAccrualAccountingEnabledOnLoanProduct()); accountingBridgeData.put("isAccountTransfer", isAccountTransfer); accountingBridgeData.put("isChargeOff", isChargedOff()); + accountingBridgeData.put("chargeOffReasonCodeValue", fetchChargeOffReasonId()); + accountingBridgeData.put("isFraud", isFraud()); final List> newLoanTransactions = new ArrayList<>(); @@ -2290,6 +2288,10 @@ public Map deriveAccountingBridgeData(final String currencyCode, return accountingBridgeData; } + private Long fetchChargeOffReasonId() { + return isChargedOff() && getChargeOffReason() != null ? getChargeOffReason().getId() : null; + } + public Money getReceivableInterest(final LocalDate tillDate) { Money receivableInterest = Money.zero(getCurrency()); for (final LoanTransaction transaction : this.loanTransactions) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanDTO.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanDTO.java index bbdf9f3c7df..8b7fd89335b 100755 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanDTO.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/data/LoanDTO.java @@ -45,5 +45,5 @@ public class LoanDTO { private boolean markedAsChargeOff; @Setter private boolean markedAsFraud; - private Integer chargeOffReasonCodeValue; + private Long chargeOffReasonCodeValue; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 5e32eb780b4..49098705f55 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -112,7 +112,7 @@ public LoanDTO populateLoanDtoFromMap(final Map accountingBridge boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer"); boolean isLoanMarkedAsChargeOff = (Boolean) accountingBridgeData.get("isChargeOff"); boolean isLoanMarkedAsFraud = (Boolean) accountingBridgeData.get("isFraud"); - final Integer chargeOffReasonCodeValue = (Integer) accountingBridgeData.get("chargeOffReasonCodeValue"); + final Long chargeOffReasonCodeValue = (Long) accountingBridgeData.get("chargeOffReasonCodeValue"); @SuppressWarnings("unchecked") final List> newTransactionsMap = (List>) accountingBridgeData.get("newLoanTransactions"); @@ -177,7 +177,7 @@ public LoanDTO populateLoanDtoFromMap(final Map accountingBridge chargeOffReasonCodeValue); } - public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Integer chargeOffReasonCodeValue) { + public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long chargeOffReasonCodeValue) { return accountMappingRepository.findChargeOffReasonMappingById(chargeOffReasonCodeValue); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java index 773fcadf12a..17c43787403 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java @@ -230,7 +230,7 @@ private void createJournalEntriesForChargeOff(LoanDTO loanDTO, LoanTransactionDT GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder(); // need to fetch if there are account mappings (always one) - Integer chargeOffReasonCodeValue = loanDTO.getChargeOffReasonCodeValue(); + Long chargeOffReasonCodeValue = loanDTO.getChargeOffReasonCodeValue(); ProductToGLAccountMapping mapping = chargeOffReasonCodeValue != null ? helper.getChargeOffMappingByCodeValue(chargeOffReasonCodeValue) diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/codes/api/CodeValuesApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/codes/api/CodeValuesApiResourceSwagger.java index 98d98dad8ef..2407ec3ba43 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/codes/api/CodeValuesApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/codes/api/CodeValuesApiResourceSwagger.java @@ -72,6 +72,8 @@ private PostCodeValueDataResponse() { @Schema(example = "4") public Long resourceId; + @Schema(example = "4") + public Long subResourceId; } @Schema(description = "PutCodeValuesDataRequest") diff --git a/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/CreateJournalEntriesForChargeOffLoanTest.java b/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/CreateJournalEntriesForChargeOffLoanTest.java index 07a183f5120..9f7be15031b 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/CreateJournalEntriesForChargeOffLoanTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/CreateJournalEntriesForChargeOffLoanTest.java @@ -48,7 +48,7 @@ @ExtendWith(MockitoExtension.class) class CreateJournalEntriesForChargeOffLoanTest { - private static final Integer chargeOffReasons = 15; + private static final Long chargeOffReasons = 15L; @Mock private AccountingProcessorHelper helper; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index b164f88a71d..845c98a977c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -159,6 +159,7 @@ public abstract class BaseLoanIntegrationTest { protected BusinessDateHelper businessDateHelper = new BusinessDateHelper(); protected DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN); protected GlobalConfigurationHelper globalConfigurationHelper = new GlobalConfigurationHelper(); + protected final CodeHelper codeHelper = new CodeHelper(); protected static void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, LocalDate dueDate, double principalDue, double principalPaid, double principalOutstanding, double paidInAdvance, double paidLate) { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingTest.java index 78c0bea501e..4b458b0bc12 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargeOffAccountingTest.java @@ -26,12 +26,21 @@ import io.restassured.http.ContentType; import io.restassured.specification.RequestSpecification; import io.restassured.specification.ResponseSpecification; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; +import java.util.Collections; import java.util.List; import java.util.UUID; +import org.apache.fineract.client.models.AllowAttributeOverrides; +import org.apache.fineract.client.models.GetCodesResponse; import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PostChargeOffReasonToExpenseAccountMappings; +import org.apache.fineract.client.models.PostCodeValueDataResponse; +import org.apache.fineract.client.models.PostCodeValuesDataRequest; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdRequest; import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdResponse; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; @@ -54,6 +63,7 @@ import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; import org.apache.fineract.integrationtests.common.system.CodeHelper; import org.apache.fineract.integrationtests.inlinecob.InlineLoanCOBHelper; +import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -789,6 +799,125 @@ public void noIncomeRecognitionAfterChargeOff() { } } + @Test + public void advancedAccountingForChargeOff() { + runAt("02 January 2023", () -> { + final Account chargeOffDelinquentExpenseAccount = accountHelper + .createExpenseAccount("delinquent_expense_for_charge_off_reason"); + GetCodesResponse chargeOffReasonCode = fetchChargeOffReasonCode(); + PostCodeValueDataResponse chargeOffReason = codeHelper.createCodeValue(chargeOffReasonCode.getId(), + new PostCodeValuesDataRequest().name(Utils.uniqueRandomStringGenerator("DELINQUENT_", 6)).isActive(true).position(10)); + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + PostLoanProductsResponse productsResponse = createLoanProductWithAdvancedChargeOffAccounting(chargeOffReason, + chargeOffDelinquentExpenseAccount); + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, productsResponse.getResourceId(), "01 January 2023", 1000.0, 1); + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023"); + + PostLoansLoanIdTransactionsResponse chargeOffTransaction = this.loanTransactionHelper.chargeOffLoan(loanId, + new PostLoansLoanIdTransactionsRequest().transactionDate("02 January 2023").locale("en").dateFormat("dd MMMM yyyy") + .chargeOffReasonId(chargeOffReason.getSubResourceId())); + // verify journal entries + verifyTRJournalEntries(chargeOffTransaction.getResourceId(), journalEntry(1000.0, loansReceivableAccount, "CREDIT"), // + journalEntry(1000.0, chargeOffDelinquentExpenseAccount, "DEBIT")); + }); + } + + private PostLoanProductsResponse createLoanProductWithAdvancedChargeOffAccounting(PostCodeValueDataResponse chargeOffReason, + Account chargeOffDelinquentExpenseAccount) { + return this.loanTransactionHelper.createLoanProduct(new PostLoanProductsRequest() + .name(Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_", 6))// + .shortName(Utils.uniqueRandomStringGenerator("", 4))// + .description("Loan Product Description")// + .includeInBorrowerCycle(false)// + .currencyCode("USD")// + .digitsAfterDecimal(2)// + .inMultiplesOf(0)// + .installmentAmountInMultiplesOf(1)// + .useBorrowerCycle(false)// + .minPrincipal(100.0)// + .principal(1000.0)// + .maxPrincipal(100000.0)// + .minNumberOfRepayments(1)// + .numberOfRepayments(1)// + .maxNumberOfRepayments(30)// + .isLinkedToFloatingInterestRates(false)// + .minInterestRatePerPeriod((double) 0)// + .interestRatePerPeriod(0.0)// + .maxInterestRatePerPeriod((double) 100)// + .interestRateFrequencyType(2)// + .repaymentEvery(30)// + .repaymentFrequencyType(0L)// + .amortizationType(1)// + .interestType(0)// + .isEqualAmortization(false)// + .interestCalculationPeriodType(1)// + .transactionProcessingStrategyCode( + LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY)// + .loanScheduleType(LoanScheduleType.CUMULATIVE.toString()) // + .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(Collections.emptyList())// + .accountingRule(3)// + .fundSourceAccountId(fundSource.getAccountID().longValue())// + .loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue())// + .transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())// + .interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue())// + .incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue())// + .incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue())// + .incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue())// + .writeOffAccountId(writtenOffAccount.getAccountID().longValue())// + .overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())// + .receivableInterestAccountId(interestReceivableAccount.getAccountID().longValue())// + .receivableFeeAccountId(feeReceivableAccount.getAccountID().longValue())// + .receivablePenaltyAccountId(penaltyReceivableAccount.getAccountID().longValue())// + .goodwillCreditAccountId(goodwillExpenseAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditFeesAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffFeesAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffPenaltyAccountId(penaltyChargeOffAccount.getAccountID().longValue())// + .chargeOffExpenseAccountId(chargeOffExpenseAccount.getAccountID().longValue())// + .chargeOffFraudExpenseAccountId(chargeOffFraudExpenseAccount.getAccountID().longValue())// + .addChargeOffReasonToExpenseAccountMappingsItem( + new PostChargeOffReasonToExpenseAccountMappings().chargeOffReasonCodeValueId(chargeOffReason.getSubResourceId()) + .expenseAccountId(chargeOffDelinquentExpenseAccount.getAccountID().longValue())) + .dateFormat(DATETIME_PATTERN)// + .locale("en_GB")// + .disallowExpectedDisbursements(true)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType("percentage")// + .overAppliedNumber(50)); + } + + private GetCodesResponse fetchChargeOffReasonCode() { + return codeHelper.retrieveCodes().stream().filter(c -> "ChargeOffReasons".equals(c.getName())).findFirst().orElseThrow(); + } + private Integer createLoanAccount(final Integer clientID, final Integer loanProductID, final String externalId) { String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("1000").withLoanTermFrequency("1") diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java index 2f0cce3635c..1ba9e1c5b72 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java @@ -27,19 +27,19 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import org.apache.fineract.client.models.GetCodesResponse; +import org.apache.fineract.client.models.PostCodeValueDataResponse; +import org.apache.fineract.client.models.PostCodeValuesDataRequest; +import org.apache.fineract.integrationtests.client.IntegrationTest; import org.apache.fineract.integrationtests.common.Utils; -public final class CodeHelper { +public final class CodeHelper extends IntegrationTest { private static final String COUNTRY_CODE_NAME = "COUNTRY"; private static final String STATE_CODE_NAME = "STATE"; private static final String ADDRESS_TYPE_CODE_NAME = "ADDRESS_TYPE"; private static final String CHARGE_OFF_REASONS_CODE_NAME = "ChargeOffReasons"; - private CodeHelper() { - - } - public static final String CODE_ID_ATTRIBUTE_NAME = "id"; public static final String RESPONSE_ID_ATTRIBUTE_NAME = "resourceId"; public static final String SUBRESPONSE_ID_ATTRIBUTE_NAME = "subResourceId"; @@ -280,4 +280,11 @@ public static Object updateCodeValue(final RequestSpecification requestSpec, fin getTestCodeValueAsJSON(codeValueName, description, position), jsonAttributeToGetback); } + public PostCodeValueDataResponse createCodeValue(Long codeId, PostCodeValuesDataRequest request) { + return ok(fineract().codeValues.createCodeValue(codeId, request)); + } + + public List retrieveCodes() { + return ok(fineract().codes.retrieveCodes()); + } }