diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index 4a1e13f7eb0..2ac66d3201f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -57,6 +57,8 @@ public enum CashAccountsForLoan { CLASSIFICATION_INCOME(22), // DEFERRED_INCOME_LIABILITY(23), // INCOME_FROM_DISCOUNT_FEE(24), // + FEES_RECEIVABLE(25), // + PENALTIES_RECEIVABLE(26), // ; private final Integer value; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeAssetOptions.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeAssetOptions.java index 45bcf7e5705..143d2723f59 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeAssetOptions.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeAssetOptions.java @@ -23,7 +23,6 @@ public enum AccountTypeAssetOptions { LOANS_RECEIVABLE(1), // INTEREST_FEE_RECEIVABLE(2), // OTHER_RECEIVABLES(3), // - UNC_RECEIVABLE(4), // FUND_RECEIVABLES(18), // TRANSFER_IN_SUSPENSE_ACCOUNT(14), // GOODWILL_TRANSFER_ACCOUNT(19); // diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeLiabilityOptions.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeLiabilityOptions.java index 9a759bfb53d..a15c80f3cb0 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeLiabilityOptions.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AccountTypeLiabilityOptions.java @@ -22,6 +22,7 @@ public enum AccountTypeLiabilityOptions { AA_SUSPENSE_BALANCE(5), // SUSPENSE_CLEARING_ACCOUNT(6), // + OTHER_CREDIT_LIABILITY(4), // OVERPAYMENT_ACCOUNT(17); // public final Integer value; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/accounttype/DefaultAccountType.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/accounttype/DefaultAccountType.java index 21531ad9737..bb883866752 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/accounttype/DefaultAccountType.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/accounttype/DefaultAccountType.java @@ -24,7 +24,6 @@ public enum DefaultAccountType implements AccountType { LOANS_RECEIVABLE("Loans Receivable"), // INTEREST_FEE_RECEIVABLE("Interest/Fee Receivable"), // OTHER_RECEIVABLES("Other Receivables"), // - UNC_RECEIVABLE("UNC Receivable"), // FUND_RECEIVABLES("Fund Receivables"), // TRANSFER_IN_SUSPENSE_ACCOUNT("Transfer in suspense account"), // ASSET_TRANSFER("Asset transfer"), // @@ -41,6 +40,7 @@ public enum DefaultAccountType implements AccountType { AA_SUSPENSE_BALANCE("AA Suspense Balance"), // SUSPENSE_CLEARING_ACCOUNT("Suspense/Clearing account"), // OVERPAYMENT_ACCOUNT("Overpayment account"), // + OTHER_CREDIT_LIABILITY("Other Credit Liability"), // DEFERRED_CAPITALIZED_INCOME("Deferred Capitalized Income"), // // Expense CREDIT_LOSS_BAD_DEBT("Credit Loss/Bad Debt"), // diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java index 89fbebfb829..f52ff039660 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java @@ -29,7 +29,9 @@ public enum DefaultWorkingCapitalLoanProduct implements WorkingCapitalLoanProduc WCLP_BREACH, // WCLP_BREACH_NEAR_BREACH, // WCLP_BREACH_DISALLOW_ATTRIBUTES_OVERRIDE, // - WCLP_BREACH_NEAR_BREACH_DISALLOW_ATTRIBUTES_OVERRIDE; // + WCLP_BREACH_NEAR_BREACH_DISALLOW_ATTRIBUTES_OVERRIDE, // + WCLP_ADVANCED_ACCOUNTING, // + WCLP_ACCOUNTING_CASH_BASED; // @Override public String getName() { diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/WCGLAccountMapping.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/WCGLAccountMapping.java index 36c4896fb28..02c0fc37560 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/WCGLAccountMapping.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/WCGLAccountMapping.java @@ -37,6 +37,12 @@ public record WCGLAccountMapping(String responseKey, boolean required, Function< public static final WCGLAccountMapping OVERPAYMENT_LIABILITY = new WCGLAccountMapping("overpaymentLiabilityAccount", true, PostWorkingCapitalLoanProductsRequest::getOverpaymentLiabilityAccountId); + // Assets — receivables (required) + public static final WCGLAccountMapping RECEIVABLE_FEE = new WCGLAccountMapping("receivableFeeAccount", true, + PostWorkingCapitalLoanProductsRequest::getReceivableFeeAccountId); + public static final WCGLAccountMapping RECEIVABLE_PENALTY = new WCGLAccountMapping("receivablePenaltyAccount", true, + PostWorkingCapitalLoanProductsRequest::getReceivablePenaltyAccountId); + // Income (required) public static final WCGLAccountMapping INCOME_FROM_DISCOUNT_FEE = new WCGLAccountMapping("incomeFromDiscountFeeAccount", true, PostWorkingCapitalLoanProductsRequest::getIncomeFromDiscountFeeAccountId); @@ -71,9 +77,10 @@ public record WCGLAccountMapping(String responseKey, boolean required, Function< PostWorkingCapitalLoanProductsRequest::getIncomeFromGoodwillCreditPenaltyAccountId); private static final List VALUES = List.of(FUND_SOURCE, LOAN_PORTFOLIO, TRANSFERS_IN_SUSPENSE, - DEFERRED_INCOME_LIABILITY, OVERPAYMENT_LIABILITY, INCOME_FROM_DISCOUNT_FEE, INCOME_FROM_FEE, INCOME_FROM_PENALTY, - INCOME_FROM_RECOVERY, WRITE_OFF, GOODWILL_CREDIT, CHARGE_OFF_EXPENSE, CHARGE_OFF_FRAUD_EXPENSE, INCOME_FROM_CHARGE_OFF_FEES, - INCOME_FROM_CHARGE_OFF_PENALTY, INCOME_FROM_GOODWILL_CREDIT_FEES, INCOME_FROM_GOODWILL_CREDIT_PENALTY); + DEFERRED_INCOME_LIABILITY, OVERPAYMENT_LIABILITY, RECEIVABLE_FEE, RECEIVABLE_PENALTY, INCOME_FROM_DISCOUNT_FEE, INCOME_FROM_FEE, + INCOME_FROM_PENALTY, INCOME_FROM_RECOVERY, WRITE_OFF, GOODWILL_CREDIT, CHARGE_OFF_EXPENSE, CHARGE_OFF_FRAUD_EXPENSE, + INCOME_FROM_CHARGE_OFF_FEES, INCOME_FROM_CHARGE_OFF_PENALTY, INCOME_FROM_GOODWILL_CREDIT_FEES, + INCOME_FROM_GOODWILL_CREDIT_PENALTY); public static List all() { return VALUES; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java index be3c460d89c..507ed9e2c1a 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java @@ -102,7 +102,9 @@ public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductReq .incomeFromChargeOffFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))// .incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))// .chargeOffExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))// - .chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD));// + .chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))// + .receivableFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))// + .receivablePenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE));// } /** diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index 186df4f2c0e..e4cf3f76be4 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -33,6 +33,7 @@ import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; +import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.math.BigDecimal; @@ -59,6 +60,7 @@ import org.apache.fineract.client.models.GetWorkingCapitalLoanProductsProductIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.JournalEntryTransactionItem; import org.apache.fineract.client.models.LoanTransactionEnumData; import org.apache.fineract.client.models.PostAllowAttributeOverrides; import org.apache.fineract.client.models.PostClientsResponse; @@ -92,6 +94,7 @@ import org.apache.fineract.test.helper.WorkingCapitalScheduleMatcher; import org.apache.fineract.test.messaging.event.EventCheckHelper; import org.apache.fineract.test.stepdef.AbstractStepDef; +import org.apache.fineract.test.stepdef.common.JournalEntriesStepDef; import org.apache.fineract.test.support.TestContextKey; import org.junit.jupiter.api.Assertions; @@ -101,6 +104,7 @@ public class WorkingCapitalLoanAccountStepDef extends AbstractStepDef { private static final String DATE_FORMAT = "dd MMMM yyyy"; private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT); private static final Long NON_EXISTENT_LOAN_ID = 999_999_999L; private static final String WC_DISBURSE_CLASSIFICATION_ID = "wcDisburseClassificationId"; private static final String WC_DISBURSE_CLASSIFICATION_CODE_NAME = "working_capital_loan_disbursement_classification"; @@ -117,6 +121,7 @@ public class WorkingCapitalLoanAccountStepDef extends AbstractStepDef { private final EventCheckHelper eventCheckHelper; private final PaymentTypeResolver paymentTypeResolver; private final BusinessDateHelper businessDateHelper; + private final JournalEntriesStepDef journalEntriesStepDef; @When("Admin creates a working capital loan with the following data:") public void createWorkingCapitalLoan(final DataTable table) { @@ -2508,4 +2513,78 @@ private void assertTable(Class tClass, List header, List transactionsMatch = findMatchingTransactions(loanId, transactionType, + transactionDate, false); + verifyJournalEntries(transactionsMatch, loanId, table); + } + + @Then("Working Capital Loan Transactions tab has {int} {string} transactions with date {string} which have the following Journal entries:") + public void verifyMultipleWorkingCapitalLoanTransactionsJournalEntries(int expectedCount, String transactionType, + String transactionDate, DataTable table) throws IOException { + Long loanId = getCreatedLoanId(); + List transactionsMatch = findMatchingTransactions(loanId, transactionType, + transactionDate, false); + + assertThat(transactionsMatch.size()).as("The number of transactions does not match the expected count! Expected: " + expectedCount + + ", Actual: " + transactionsMatch.size()).isEqualTo(expectedCount); + + verifyJournalEntries(transactionsMatch, loanId, table); + } + + @Then("Working Capital Loan Transactions tab has a reversed {string} transaction with date {string} which has the following Journal entries:") + public void verifyReversedWorkingCapitalLoanTransactionJournalEntries(String transactionType, String transactionDate, DataTable table) + throws IOException { + Long loanId = getCreatedLoanId(); + List transactionsMatch = findMatchingTransactions(loanId, transactionType, + transactionDate, true); + verifyJournalEntries(transactionsMatch, loanId, table); + } + + private List findMatchingTransactions(Long loanId, String transactionType, + String transactionDate, boolean reversed) { + GetWorkingCapitalLoansLoanIdResponse loanDetailsResponse = ok( + () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + + return loanDetailsResponse.getTransactions().stream() + .filter(t -> t.getType() != null && transactionDate.equals(DATE_FORMATTER.format(t.getTransactionDate())) + && transactionType.equalsIgnoreCase(t.getType().getValue()) + && (reversed ? Boolean.TRUE.equals(t.getReversed()) : !Boolean.TRUE.equals(t.getReversed()))) + .collect(Collectors.toList()); + } + + private void verifyJournalEntries(List transactions, Long loanId, DataTable table) { + List> journalLinesActualList = getWorkingCapitalJournalLinesActualList(transactions); + journalEntriesStepDef.checkJournalEntryData(journalLinesActualList, loanId, table); + } + + private List> getWorkingCapitalJournalLinesActualList( + List transactions) { + log.debug("Processing {} working capital loan transactions for journal entries", transactions.size()); + return transactions.stream().map(this::retrieveJournalEntriesForTransaction).collect(Collectors.toList()); + } + + private List retrieveJournalEntriesForTransaction(GetWorkingCapitalLoanTransactionIdResponse transaction) { + String transactionId = "WC" + transaction.getId(); + log.debug("Retrieving journal entries for working capital transaction: {}", transactionId); + + JournalEntriesApi.RetrieveAllJournalEntriesQueryParams params = new JournalEntriesApi.RetrieveAllJournalEntriesQueryParams() + .transactionId(transactionId).runningBalance(true); + + GetJournalEntriesTransactionIdResponse journalEntryDataResponse = ok( + () -> fineractClient.journalEntries().retrieveAllJournalEntries(params)); + + return journalEntryDataResponse != null && journalEntryDataResponse.getPageItems() != null ? journalEntryDataResponse.getPageItems() + : List.of(); + } + + @When("Customer undo {string}th {string} transaction made on {string} on Working Capital loan") + public void undoWorkingCapitalLoanTransaction(String nthItemStr, String transactionType, String transactionDate) throws IOException { + // TODO: Implement undo transaction for working capital loans when backend support is available (PS-3194) + throw new UnsupportedOperationException("Undo transaction for working capital loans is not yet implemented"); + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java index f9da6bf6981..345cd738ab0 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java @@ -1114,7 +1114,9 @@ private PutWorkingCapitalLoanProductsProductIdRequest buildCashBasedUpdateReques .incomeFromPenaltyAccountId(source.getIncomeFromPenaltyAccountId())// .incomeFromRecoveryAccountId(source.getIncomeFromRecoveryAccountId())// .writeOffAccountId(source.getWriteOffAccountId())// - .overpaymentLiabilityAccountId(source.getOverpaymentLiabilityAccountId()); + .overpaymentLiabilityAccountId(source.getOverpaymentLiabilityAccountId())// + .receivableFeeAccountId(source.getReceivableFeeAccountId())// + .receivablePenaltyAccountId(source.getReceivablePenaltyAccountId()); } public void checkWorkingCapitalLoanProductCreate() { @@ -2012,6 +2014,8 @@ private PostWorkingCapitalLoanProductsRequest buildAdvancedMappingsRequest( .incomeFromRecoveryAccountId(accountTypeResolver.resolve(DefaultAccountType.RECOVERIES)) .writeOffAccountId(accountTypeResolver.resolve(DefaultAccountType.WRITTEN_OFF)) .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OVERPAYMENT_ACCOUNT)) + .receivableFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE)) + .receivablePenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE)) .paymentChannelToFundSourceMappings(paymentChannelMappings).chargeOffReasonToExpenseAccountMappings(chargeOffMappings) .writeOffReasonsToExpenseMappings(writeOffMappings).feeToIncomeAccountMappings(List.of()) .penaltyToIncomeAccountMappings(List.of()); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 9db44f7aea0..75d67a25834 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -332,6 +332,8 @@ public abstract class TestContextKey { public static final String DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_BREACH_NEAR_BREACH = "workingCapitalLoanProductCreateResponseWCLPBreachNearBreach"; public static final String DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_BREACH_DISALLOW_OVERRIDES = "workingCapitalLoanProductCreateResponseWCLPBreachDisallowOverrides"; public static final String DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_BREACH_NEAR_BREACH_DISALLOW_OVERRIDES = "workingCapitalLoanProductCreateResponseWCLPBreachNearBreachDisallowOverrides"; + public static final String DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_ADVANCED_ACCOUNTING = "workingCapitalLoanProductCreateResponseWCLPAdvancedAccounting"; + public static final String DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_ACCOUNTING_CASH_BASED = "workingCapitalLoanProductCreateResponseWCLPAccountingCashBased"; public static final String WC_LOAN_IDS = "wcLoanIds"; public static final String DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST_FOR_UPDATE_WCLP = "workingCapitalLoanProductCreateRequestForUpdateWCLP"; public static final String DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_FOR_UPDATE_WCLP = "workingCapitalLoanProductCreateResponseForUpdateWCLP"; diff --git a/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/GLGlobalInitializerStep.java b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/GLGlobalInitializerStep.java index 1d69ec536d4..08229edace3 100644 --- a/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/GLGlobalInitializerStep.java +++ b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/GLGlobalInitializerStep.java @@ -50,7 +50,7 @@ public class GLGlobalInitializerStep implements FineractGlobalInitializerStep { public static final String GLA_NAME_1 = "Loans Receivable"; public static final String GLA_NAME_2 = "Interest/Fee Receivable"; public static final String GLA_NAME_3 = "Other Receivables"; - public static final String GLA_NAME_4 = "UNC Receivable"; + public static final String GLA_NAME_4 = "Other Credit Liability"; public static final String GLA_NAME_5 = "AA Suspense Balance"; public static final String GLA_NAME_6 = "Suspense/Clearing account"; public static final String GLA_NAME_7 = "Deferred Interest Revenue"; @@ -112,7 +112,7 @@ public void initialize() { List items = List.of(new GLAccountDefinition(GLA_NAME_1, GLA_GL_CODE_1, GLA_TYPE_ASSET), new GLAccountDefinition(GLA_NAME_2, GLA_GL_CODE_2, GLA_TYPE_ASSET), new GLAccountDefinition(GLA_NAME_3, GLA_GL_CODE_3, GLA_TYPE_ASSET), - new GLAccountDefinition(GLA_NAME_4, GLA_GL_CODE_4, GLA_TYPE_ASSET), + new GLAccountDefinition(GLA_NAME_4, GLA_GL_CODE_4, GLA_TYPE_LIABILITY), new GLAccountDefinition(GLA_NAME_5, GLA_GL_CODE_5, GLA_TYPE_LIABILITY), new GLAccountDefinition(GLA_NAME_6, GLA_GL_CODE_6, GLA_TYPE_LIABILITY), new GLAccountDefinition(GLA_NAME_7, GLA_GL_CODE_7, GLA_TYPE_INCOME), diff --git a/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java index 955557875bf..f8f66feec73 100644 --- a/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java +++ b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java @@ -30,8 +30,15 @@ import org.apache.fineract.client.models.PostAllowAttributeOverrides; import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsResponse; +import org.apache.fineract.test.data.accounttype.AccountTypeResolver; +import org.apache.fineract.test.data.accounttype.DefaultAccountType; +import org.apache.fineract.test.data.codevalue.CodeValueResolver; +import org.apache.fineract.test.data.codevalue.DefaultCodeValue; +import org.apache.fineract.test.data.paymenttype.DefaultPaymentType; +import org.apache.fineract.test.data.paymenttype.PaymentTypeResolver; import org.apache.fineract.test.data.workingcapitalproduct.DefaultWorkingCapitalLoanProduct; import org.apache.fineract.test.factory.WorkingCapitalRequestFactory; +import org.apache.fineract.test.helper.CodeHelper; import org.apache.fineract.test.helper.ParallelExecutionHelper; import org.apache.fineract.test.support.TestContext; import org.apache.fineract.test.support.TestContextKey; @@ -44,6 +51,10 @@ public class WorkingCapitalInitializerStep implements FineractGlobalInitializerS private final FineractFeignClient fineractClient; private final WorkingCapitalRequestFactory workingCapitalRequestFactory; + private final PaymentTypeResolver paymentTypeResolver; + private final AccountTypeResolver accountTypeResolver; + private final CodeValueResolver codeValueResolver; + private final CodeHelper codeHelper; @Override public void initialize() throws Exception { @@ -51,6 +62,13 @@ public void initialize() throws Exception { .delinquencyBucketClassification(false).discountDefault(false).periodPaymentFrequencyType(false) .periodPaymentFrequency(false).breach(false); + PostAllowAttributeOverrides allowAttributeOverrides = new PostAllowAttributeOverrides().delinquencyBucketClassification(true) + .breach(true).discountDefault(true).periodPaymentFrequencyType(true).periodPaymentFrequency(true); + + // Retrieve code IDs for charge-off and write-off reasons + final Long chargeOffReasonCodeId = codeHelper.retrieveCodeByName("ChargeOffReasons").getId(); + final Long writeOffReasonCodeId = codeHelper.retrieveCodeByName("WriteOffReasons").getId(); + List items = List.of( () -> TestContext.INSTANCE.set(TestContextKey.DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP, createWorkingCapitalLoanProductIdempotent( @@ -95,10 +113,39 @@ public void initialize() throws Exception { .name(DefaultWorkingCapitalLoanProduct.WCLP_BREACH_DISALLOW_ATTRIBUTES_OVERRIDE.getName()))), () -> TestContext.INSTANCE.set( TestContextKey.DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_BREACH_NEAR_BREACH_DISALLOW_OVERRIDES, + createWorkingCapitalLoanProductIdempotent( + workingCapitalRequestFactory.defaultWorkingCapitalLoanProductBreachNearBreachRequest() + .allowAttributeOverrides(allowAttributeOverridesDisabled) + .name(DefaultWorkingCapitalLoanProduct.WCLP_BREACH_NEAR_BREACH_DISALLOW_ATTRIBUTES_OVERRIDE + .getName()))), + () -> TestContext.INSTANCE.set(TestContextKey.DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_ADVANCED_ACCOUNTING, createWorkingCapitalLoanProductIdempotent(workingCapitalRequestFactory - .defaultWorkingCapitalLoanProductBreachNearBreachRequest() - .allowAttributeOverrides(allowAttributeOverridesDisabled) - .name(DefaultWorkingCapitalLoanProduct.WCLP_BREACH_NEAR_BREACH_DISALLOW_ATTRIBUTES_OVERRIDE.getName())))); + .defaultWorkingCapitalLoanProductRequestWithCashAccounting() + .name(DefaultWorkingCapitalLoanProduct.WCLP_ADVANCED_ACCOUNTING.getName()) + .allowAttributeOverrides(allowAttributeOverrides) + .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OTHER_CREDIT_LIABILITY)) + .paymentChannelToFundSourceMappings( + List.of(new org.apache.fineract.client.models.WorkingCapitalLoanPaymentChannelToFundSourceMappings() + .paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER)) + .fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.FUND_RECEIVABLES)))) + .chargeOffReasonToExpenseAccountMappings(List.of( + new org.apache.fineract.client.models.WorkingCapitalPostChargeOffReasonToExpenseAccountMappings() + .chargeOffReasonCodeValueId( + codeValueResolver.resolve(chargeOffReasonCodeId, DefaultCodeValue.valueOf("FRAUD"))) + .expenseAccountId( + accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD)))) + .writeOffReasonsToExpenseMappings(List + .of(new org.apache.fineract.client.models.WorkingCapitalPostWriteOffReasonToExpenseAccountMappings() + .writeOffReasonCodeValueId(codeValueResolver.resolve(writeOffReasonCodeId, + DefaultCodeValue.valueOf("BAD_DEBT"))) + .expenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT)))))), + () -> TestContext.INSTANCE.set( + TestContextKey.DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP_ACCOUNTING_CASH_BASED, + createWorkingCapitalLoanProductIdempotent( + workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequestWithCashAccounting() + .name(DefaultWorkingCapitalLoanProduct.WCLP_ACCOUNTING_CASH_BASED.getName()) + .allowAttributeOverrides(allowAttributeOverrides).overpaymentLiabilityAccountId( + accountTypeResolver.resolve(DefaultAccountType.OTHER_CREDIT_LIABILITY))))); ParallelExecutionHelper.runInParallel(items); } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentAccountingEntries.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentAccountingEntries.feature new file mode 100644 index 00000000000..52c402b04ff --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepaymentAccountingEntries.feature @@ -0,0 +1,266 @@ +@WorkingCapitalLoanRepaymentAccountingEntriesFeature +Feature: Working Capital Loan Repayment Accounting Entries + + @TestRailId:C78870 + Scenario: Verify Working Capital loan repayment GL entries - UC1: simple principal repayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 270.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 270.0 | | + | ASSET | 112601 | Loans Receivable | | 270.0 | + + @TestRailId:C78871 + Scenario: Verify Working Capital loan repayment GL entries - UC2: multiple payments same day + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 170.0 transaction amount on Working Capital loan + And Customer makes repayment on "02 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has 2 "REPAYMENT" transactions with date "02 January 2026" which have the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 170.0 | | + | ASSET | 112601 | Loans Receivable | | 170.0 | + | LIABILITY | 145023 | Suspense/Clearing account | 100.0 | | + | ASSET | 112601 | Loans Receivable | | 100.0 | + + @TestRailId:C78872 + Scenario: Verify Working Capital loan repayment GL entries - UC3: multiple payments different days + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 170.0 transaction amount on Working Capital loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "02 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 170.0 | | + | ASSET | 112601 | Loans Receivable | | 170.0 | + And Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 100.0 | | + | ASSET | 112601 | Loans Receivable | | 100.0 | + + @TestRailId:C78873 + Scenario: Verify Working Capital loan repayment GL entries - UC4: repayment with overpayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 10000.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 9000.0 | + | LIABILITY | 245000 | Other Credit Liability | | 1000.0 | + + @Skip @RepaymentGLEntriesFee + Scenario: Verify Working Capital loan repayment GL entries - UC5: repayment allocates to fees + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount +# TODO Add fee here to Working Capital loan + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 320.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 320.0 | | + | ASSET | 112601 | Loans Receivable | | 270.0 | + | ASSET | 112603 | Fee Receivable | | 50.0 | + + @Skip @RepaymentGLEntriesPenalty + Scenario: Verify Working Capital loan repayment GL entries - UC6: repayment allocates to penalties + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount +# TODO Add penalty here to Working Capital loan + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 300.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 300.0 | | + | ASSET | 112601 | Loans Receivable | | 270.0 | + | ASSET | 112603 | Fee Receivable | | 50.0 | + + @Skip @RepaymentGLEntriesFeePenaltyOverpayment + Scenario: Verify Working Capital loan repayment GL entries - UC7: complex allocation with fees, penalties, and overpayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount +# TODO Add fee + penalty here to Working Capital loan + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 10500.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 10500.0 | | + | ASSET | 112601 | Loans Receivable | | 9000.0 | + | ASSET | 112603 | Fee Receivable | | 80.0 | + | LIABILITY | 245000 | Other Credit liability | | 1420.0 | + + @TestRailId:C78874 + Scenario: Verify Working Capital loan repayment GL entries - UC8: partial repayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 100.0 | | + | ASSET | 112601 | Loans Receivable | | 100.0 | + +# TODO Check and update when PS-3194 is done + @Skip @UndoRepaymentGLEntries1 + Scenario: Verify Working Capital loan UNDO repayment GL entries - UC1: simple reversal + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 270.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 270.0 | | + | ASSET | 112601 | Loans Receivable | | 270.0 | + When Customer undo "1"th "Repayment" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112601 | Loans Receivable | 270.0 | | + | LIABILITY | 145023 | Suspense/Clearing account | | 270.0 | + + # TODO Check and update when PS-3194 is done + @Skip @UndoRepaymentGLEntries2 + Scenario: Verify Working Capital loan UNDO repayment GL entries - UC2: reversal with fees + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds "LOAN_SNOOZE_FEE" due date charge with "05 January 2026" due date and 50.0 EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 320.0 transaction amount on Working Capital loan + When Customer undo "1"th "Repayment" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112601 | Loans Receivable | 270.0 | | + | ASSET | 112603 | Fee Receivable | 50.0 | | + | LIABILITY | 145023 | Suspense/Clearing account | | 320.0 | + + # TODO Check and update when PS-3194 is done + @Skip @UndoRepaymentGLEntries3 + Scenario: Verify Working Capital loan UNDO repayment GL entries - UC3: reversal with penalties + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds "LOAN_NSF_FEE" due date charge with "05 January 2026" due date and 30.0 EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 300.0 transaction amount on Working Capital loan + When Customer undo "1"th "Repayment" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112601 | Loans Receivable | 270.0 | | + | ASSET | 112603 | Fee Receivable | 30.0 | | + | LIABILITY | 145023 | Suspense/Clearing account | | 300.0 | + + # TODO Check and update when PS-3194 is done + @Skip @UndoRepaymentGLEntries4 + Scenario: Verify Working Capital loan UNDO repayment GL entries - UC4: reversal with overpayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 10000.0 transaction amount on Working Capital loan + When Customer undo "1"th "Repayment" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112601 | Loans Receivable | 9000.0 | | + | LIABILITY | 245000 | Other Credit liability | 1000.0 | | + | LIABILITY | 145023 | Suspense/Clearing account | | 10000.0 | + + @TestRailId:C78875 + Scenario: Verify Working Capital loan repayment GL entries - UC13: Advanced Accounting, payment channel fund source mapping + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 270.0 transaction amount on Working Capital loan with the following payment details: + | paymentType | accountNumber | checkNumber | routingCode | receiptNumber | bankNumber | + | MONEY_TRANSFER | | | | | | + Then Working Capital Loan Transactions tab has a "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 987654 | Fund Receivables | 270.0 | | + | ASSET | 112601 | Loans Receivable | | 270.0 | + + # TODO Check and update when WC - Transaction Type- Repayment- Backdated and Undo Repayment is done + @Skip @UndoRepaymentGLEntries5 + Scenario: Verify Working Capital loan UNDO repayment GL entries - UC5: Advanced Accounting, payment channel fund source mapping, reversal + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 270.0 transaction amount on Working Capital loan with the following payment details: + | paymentType | accountNumber | checkNumber | routingCode | receiptNumber | bankNumber | + | MONEY_TRANSFER | | | | | | + When Customer undo "1"th "Repayment" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "REPAYMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 987654 | Fund Receivables | 270.0 | | + | LIABILITY | 145023 | Suspense/Clearing account | | 270.0 | 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 21e81186b53..c3dda2c6442 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 @@ -74,6 +74,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; import org.springframework.dao.DataAccessException; @@ -86,6 +87,7 @@ public class AccountingProcessorHelper { public static final String CLIENT_TRANSACTION_IDENTIFIER = "C"; public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; + public static final String WORKING_CAPITAL_LOAN_TRANSACTION_IDENTIFIER = "WC"; private final JournalEntryRepository glJournalEntryRepository; private final ProductToGLAccountMappingRepository accountMappingRepository; @@ -957,6 +959,47 @@ public void createDebitJournalEntryForLoan(final Office office, final String cur persistJournalEntry(journalEntry); } + public void createCreditJournalEntryForWorkingCapitalLoan(final Office office, final String currencyCode, final GLAccount account, + final Long workingCapitalLoanId, final Long workingCapitalLoanTransactionId, final LocalDate transactionDate, + final BigDecimal amount, final PaymentDetail paymentDetail) { + final String modifiedTransactionId = WORKING_CAPITAL_LOAN_TRANSACTION_IDENTIFIER + workingCapitalLoanTransactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, paymentDetail, account, currencyCode, modifiedTransactionId, false, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.WORKING_CAPITAL_LOAN.getValue(), + workingCapitalLoanId, null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createDebitJournalEntryForWorkingCapitalLoan(final Office office, final String currencyCode, final GLAccount account, + final Long workingCapitalLoanId, final Long workingCapitalLoanTransactionId, final LocalDate transactionDate, + final BigDecimal amount, final PaymentDetail paymentDetail) { + final String modifiedTransactionId = WORKING_CAPITAL_LOAN_TRANSACTION_IDENTIFIER + workingCapitalLoanTransactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, paymentDetail, account, currencyCode, modifiedTransactionId, false, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.WORKING_CAPITAL_LOAN.getValue(), + workingCapitalLoanId, null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public GLAccount getLinkedGLAccountForWorkingCapitalLoanProduct(final Long workingCapitalLoanProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping( + workingCapitalLoanProductId, PortfolioProductType.WORKING_CAPITAL_LOAN.getValue(), accountMappingTypeId); + + if (accountMappingTypeId == CashAccountsForLoan.FUND_SOURCE.getValue() && paymentTypeId != null) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(workingCapitalLoanProductId, + PortfolioProductType.WORKING_CAPITAL_LOAN.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.WORKING_CAPITAL_LOAN, workingCapitalLoanProductId, + CashAccountsForLoan.fromInt(accountMappingTypeId).toString()); + } + return accountMapping.getGlAccount(); + } + private void createDebitJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { final boolean manualEntry = false; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForWorkingCapitalLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForWorkingCapitalLoan.java new file mode 100644 index 00000000000..3f11a4813d0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForWorkingCapitalLoan.java @@ -0,0 +1,173 @@ +/** + * 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.accounting.journalentry.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CashBasedAccountingProcessorForWorkingCapitalLoan implements WorkingCapitalLoanAccountingProcessor { + + private static final int WORKING_CAPITAL_LOAN_ENTITY_TYPE = PortfolioProductType.WORKING_CAPITAL_LOAN.getValue(); + + private final AccountingProcessorHelper helper; + private final JournalEntryRepository journalEntryRepository; + + @Override + public void postJournalEntriesForRepayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn, + final WorkingCapitalLoanTransactionAllocation allocation, final boolean isChargedOff) { + final Office office = loan.getClient().getOffice(); + final Long productId = loan.getLoanProduct().getId(); + final String currencyCode = loan.getLoanProductRelatedDetails().getCurrency().getCode(); + final LocalDate transactionDate = txn.getTransactionDate(); + final Long paymentTypeId = extractPaymentTypeId(txn); + + helper.checkForBranchClosures(helper.getLatestClosureByBranch(office.getId()), transactionDate); + + final BigDecimal principalPortion = MathUtil.nullToZero(allocation.getPrincipalPortion()); + final BigDecimal feesPortion = MathUtil.nullToZero(allocation.getFeeChargesPortion()); + final BigDecimal penaltiesPortion = MathUtil.nullToZero(allocation.getPenaltyChargesPortion()); + final BigDecimal overpaymentPortion = txn.getTransactionAmount().subtract(principalPortion).subtract(feesPortion) + .subtract(penaltiesPortion).max(BigDecimal.ZERO); + + if (isChargedOff) { + postChargedOffRepaymentEntries(office, productId, currencyCode, transactionDate, paymentTypeId, txn, principalPortion, + feesPortion, penaltiesPortion, overpaymentPortion); + } else { + postRegularRepaymentEntries(office, productId, currencyCode, transactionDate, paymentTypeId, txn, principalPortion, feesPortion, + penaltiesPortion, overpaymentPortion); + } + } + + @Override + public void postReversalJournalEntries(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn) { + final Office office = loan.getClient().getOffice(); + final LocalDate transactionDate = txn.getReversedOnDate() != null ? txn.getReversedOnDate() : DateUtils.getBusinessLocalDate(); + + helper.checkForBranchClosures(helper.getLatestClosureByBranch(office.getId()), transactionDate); + + final String transactionId = AccountingProcessorHelper.WORKING_CAPITAL_LOAN_TRANSACTION_IDENTIFIER + txn.getId(); + final List existingEntries = journalEntryRepository.findJournalEntries(transactionId, + WORKING_CAPITAL_LOAN_ENTITY_TYPE); + + for (final JournalEntry journalEntry : existingEntries) { + final JournalEntryType reversalType = journalEntry.isDebitEntry() ? JournalEntryType.CREDIT : JournalEntryType.DEBIT; + final JournalEntry reversalEntry = JournalEntry.createNew(journalEntry.getOffice(), journalEntry.getPaymentDetail(), + journalEntry.getGlAccount(), journalEntry.getCurrencyCode(), transactionId, Boolean.FALSE, transactionDate, + reversalType, journalEntry.getAmount(), journalEntry.getDescription(), journalEntry.getEntityType(), + journalEntry.getEntityId(), journalEntry.getReferenceNumber(), journalEntry.getLoanTransactionId(), + journalEntry.getSavingsTransactionId(), journalEntry.getClientTransactionId(), journalEntry.getShareTransactionId()); + helper.persistJournalEntry(reversalEntry); + + journalEntry.setReversed(true); + journalEntry.setReversalJournalEntry(reversalEntry); + helper.persistJournalEntry(journalEntry); + } + } + + private void postRegularRepaymentEntries(final Office office, final Long productId, final String currencyCode, + final LocalDate transactionDate, final Long paymentTypeId, final WorkingCapitalLoanTransaction txn, + final BigDecimal principalPortion, final BigDecimal feesPortion, final BigDecimal penaltiesPortion, + final BigDecimal overpaymentPortion) { + postRepaymentCreditEntries(office, productId, currencyCode, transactionDate, txn, principalPortion, + CashAccountsForLoan.LOAN_PORTFOLIO, feesPortion, CashAccountsForLoan.FEES_RECEIVABLE, penaltiesPortion, + CashAccountsForLoan.PENALTIES_RECEIVABLE, overpaymentPortion); + postFundSourceDebit(office, productId, currencyCode, transactionDate, paymentTypeId, txn); + } + + private void postChargedOffRepaymentEntries(final Office office, final Long productId, final String currencyCode, + final LocalDate transactionDate, final Long paymentTypeId, final WorkingCapitalLoanTransaction txn, + final BigDecimal principalPortion, final BigDecimal feesPortion, final BigDecimal penaltiesPortion, + final BigDecimal overpaymentPortion) { + postRepaymentCreditEntries(office, productId, currencyCode, transactionDate, txn, principalPortion, + CashAccountsForLoan.INCOME_FROM_RECOVERY, feesPortion, CashAccountsForLoan.INCOME_FROM_RECOVERY, penaltiesPortion, + CashAccountsForLoan.INCOME_FROM_RECOVERY, overpaymentPortion); + postFundSourceDebit(office, productId, currencyCode, transactionDate, paymentTypeId, txn); + } + + private void postRepaymentCreditEntries(final Office office, final Long productId, final String currencyCode, + final LocalDate transactionDate, final WorkingCapitalLoanTransaction txn, final BigDecimal principalPortion, + final CashAccountsForLoan principalAccountType, final BigDecimal feesPortion, final CashAccountsForLoan feesAccountType, + final BigDecimal penaltiesPortion, final CashAccountsForLoan penaltiesAccountType, final BigDecimal overpaymentPortion) { + final Long loanId = txn.getWcLoan().getId(); + final Long txnId = txn.getId(); + + if (MathUtil.isGreaterThanZero(principalPortion)) { + final GLAccount account = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, principalAccountType.getValue(), + null); + helper.createCreditJournalEntryForWorkingCapitalLoan(office, currencyCode, account, loanId, txnId, transactionDate, + principalPortion, null); + } + + if (MathUtil.isGreaterThanZero(feesPortion)) { + final GLAccount account = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, feesAccountType.getValue(), null); + helper.createCreditJournalEntryForWorkingCapitalLoan(office, currencyCode, account, loanId, txnId, transactionDate, feesPortion, + null); + } + + if (MathUtil.isGreaterThanZero(penaltiesPortion)) { + final GLAccount account = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, penaltiesAccountType.getValue(), + null); + helper.createCreditJournalEntryForWorkingCapitalLoan(office, currencyCode, account, loanId, txnId, transactionDate, + penaltiesPortion, null); + } + + if (MathUtil.isGreaterThanZero(overpaymentPortion)) { + final GLAccount account = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, + CashAccountsForLoan.OVERPAYMENT.getValue(), null); + helper.createCreditJournalEntryForWorkingCapitalLoan(office, currencyCode, account, loanId, txnId, transactionDate, + overpaymentPortion, null); + } + } + + private void postFundSourceDebit(final Office office, final Long productId, final String currencyCode, final LocalDate transactionDate, + final Long paymentTypeId, final WorkingCapitalLoanTransaction txn) { + final BigDecimal totalAmount = txn.getTransactionAmount(); + if (MathUtil.isGreaterThanZero(totalAmount)) { + final GLAccount account = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, + CashAccountsForLoan.FUND_SOURCE.getValue(), paymentTypeId); + helper.createDebitJournalEntryForWorkingCapitalLoan(office, currencyCode, account, txn.getWcLoan().getId(), txn.getId(), + transactionDate, totalAmount, txn.getPaymentDetail()); + } + } + + private Long extractPaymentTypeId(final WorkingCapitalLoanTransaction txn) { + if (txn.getPaymentDetail() != null && txn.getPaymentDetail().getPaymentType() != null) { + return txn.getPaymentDetail().getPaymentType().getId(); + } + return null; + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForWorkingCapitalLoanTest.java b/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForWorkingCapitalLoanTest.java new file mode 100644 index 00000000000..c99d0dd6ba6 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForWorkingCapitalLoanTest.java @@ -0,0 +1,265 @@ +/** + * 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.accounting.journalentry.service; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; +import org.apache.fineract.portfolio.paymenttype.domain.PaymentType; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CashBasedAccountingProcessorForWorkingCapitalLoanTest { + + private static final Long PRODUCT_ID = 10L; + private static final Long LOAN_ID = 100L; + private static final Long TXN_ID = 200L; + private static final String CURRENCY_CODE = "USD"; + private static final int WORKING_CAPITAL_LOAN_ENTITY_TYPE = PortfolioProductType.WORKING_CAPITAL_LOAN.getValue(); + + @Mock + private AccountingProcessorHelper helper; + @Mock + private JournalEntryRepository journalEntryRepository; + + @InjectMocks + private CashBasedAccountingProcessorForWorkingCapitalLoan processor; + + @Mock + private WorkingCapitalLoan loan; + @Mock + private WorkingCapitalLoanTransaction txn; + @Mock + private WorkingCapitalLoanTransactionAllocation allocation; + @Mock + private WorkingCapitalLoanProduct loanProduct; + @Mock + private WorkingCapitalLoanProductRelatedDetails loanProductRelatedDetails; + @Mock + private MonetaryCurrency currency; + @Mock + private Client client; + @Mock + private Office office; + + @Mock + private GLAccount fundSourceGLAccount; + @Mock + private GLAccount loanPortfolioGLAccount; + @Mock + private GLAccount overpaymentGLAccount; + @Mock + private GLAccount feesReceivableGLAccount; + @Mock + private GLAccount penaltiesReceivableGLAccount; + @Mock + private GLAccount incomeFromRecoveryGLAccount; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setBusinessDates(new HashMap<>( + Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.of(2026, 5, 1), BusinessDateType.COB_DATE, LocalDate.of(2026, 4, 30)))); + + lenient().when(loan.getClient()).thenReturn(client); + lenient().when(client.getOffice()).thenReturn(office); + lenient().when(office.getId()).thenReturn(1L); + lenient().when(loan.getLoanProduct()).thenReturn(loanProduct); + lenient().when(loanProduct.getId()).thenReturn(PRODUCT_ID); + lenient().when(loan.getLoanProductRelatedDetails()).thenReturn(loanProductRelatedDetails); + lenient().when(loanProductRelatedDetails.getCurrency()).thenReturn(currency); + lenient().when(currency.getCode()).thenReturn(CURRENCY_CODE); + lenient().when(txn.getWcLoan()).thenReturn(loan); + lenient().when(loan.getId()).thenReturn(LOAN_ID); + lenient().when(txn.getId()).thenReturn(TXN_ID); + lenient().when(txn.getTransactionDate()).thenReturn(LocalDate.of(2026, 5, 1)); + lenient().when(txn.getPaymentDetail()).thenReturn(null); + lenient().when(helper.getLatestClosureByBranch(anyLong())).thenReturn(null); + + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), eq(CashAccountsForLoan.FUND_SOURCE.getValue()), + any())).thenReturn(fundSourceGLAccount); + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), + eq(CashAccountsForLoan.LOAN_PORTFOLIO.getValue()), any())).thenReturn(loanPortfolioGLAccount); + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), eq(CashAccountsForLoan.OVERPAYMENT.getValue()), + any())).thenReturn(overpaymentGLAccount); + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), + eq(CashAccountsForLoan.FEES_RECEIVABLE.getValue()), any())).thenReturn(feesReceivableGLAccount); + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), + eq(CashAccountsForLoan.PENALTIES_RECEIVABLE.getValue()), any())).thenReturn(penaltiesReceivableGLAccount); + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), + eq(CashAccountsForLoan.INCOME_FROM_RECOVERY.getValue()), any())).thenReturn(incomeFromRecoveryGLAccount); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.reset(); + } + + @Test + void testRegularRepaymentWithFeesAndPenalties() { + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("1500")); + when(allocation.getPrincipalPortion()).thenReturn(new BigDecimal("1000")); + when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("300")); + when(allocation.getPenaltyChargesPortion()).thenReturn(new BigDecimal("200")); + + processor.postJournalEntriesForRepayment(loan, txn, allocation, false); + + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(loanPortfolioGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("1000")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(feesReceivableGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("300")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(penaltiesReceivableGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("200")), isNull()); + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(fundSourceGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("1500")), isNull()); + } + + @Test + void testRegularRepaymentWithOverpayment() { + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("5200")); + when(allocation.getPrincipalPortion()).thenReturn(new BigDecimal("5000")); + when(allocation.getFeeChargesPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + processor.postJournalEntriesForRepayment(loan, txn, allocation, false); + + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(loanPortfolioGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("5000")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(overpaymentGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("200")), isNull()); + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(fundSourceGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("5200")), isNull()); + } + + @Test + void testChargedOffRepaymentCreatesSeparateRecoveryEntries() { + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("1500")); + when(allocation.getPrincipalPortion()).thenReturn(new BigDecimal("1000")); + when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("300")); + when(allocation.getPenaltyChargesPortion()).thenReturn(new BigDecimal("200")); + + processor.postJournalEntriesForRepayment(loan, txn, allocation, true); + + // 3 separate credit entries to recovery income + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromRecoveryGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("1000")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromRecoveryGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("300")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromRecoveryGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("200")), isNull()); + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(fundSourceGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("1500")), isNull()); + } + + @Test + void testChargedOffRepaymentWithOverpayment() { + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("6000")); + when(allocation.getPrincipalPortion()).thenReturn(new BigDecimal("5000")); + when(allocation.getFeeChargesPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + processor.postJournalEntriesForRepayment(loan, txn, allocation, true); + + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromRecoveryGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("5000")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(overpaymentGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("1000")), isNull()); + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(fundSourceGLAccount), eq(LOAN_ID), eq(TXN_ID), + any(), eq(new BigDecimal("6000")), isNull()); + } + + @Test + void testReversalCreatesInverseEntriesAndMarksOriginalReversed() { + when(txn.getReversedOnDate()).thenReturn(LocalDate.of(2026, 5, 2)); + + JournalEntry originalDebit = JournalEntry.createNew(office, null, fundSourceGLAccount, CURRENCY_CODE, "WC" + TXN_ID, false, + LocalDate.of(2026, 5, 1), JournalEntryType.DEBIT, new BigDecimal("5000"), null, WORKING_CAPITAL_LOAN_ENTITY_TYPE, LOAN_ID, null, TXN_ID, null, + null, null); + JournalEntry originalCredit = JournalEntry.createNew(office, null, loanPortfolioGLAccount, CURRENCY_CODE, "WC" + TXN_ID, false, + LocalDate.of(2026, 5, 1), JournalEntryType.CREDIT, new BigDecimal("5000"), null, WORKING_CAPITAL_LOAN_ENTITY_TYPE, LOAN_ID, null, TXN_ID, + null, null, null); + + when(journalEntryRepository.findJournalEntries("WC" + TXN_ID, WORKING_CAPITAL_LOAN_ENTITY_TYPE)) + .thenReturn(List.of(originalDebit, originalCredit)); + when(helper.persistJournalEntry(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + processor.postReversalJournalEntries(loan, txn); + + // 4 persists: 2 reversals + 2 originals marked reversed + verify(helper, org.mockito.Mockito.times(4)).persistJournalEntry(any()); + assertTrue(originalDebit.isReversed()); + assertTrue(originalCredit.isReversed()); + } + + @Test + void testAdvanceAccountingUsesPaymentChannelFundSource() { + GLAccount paymentChannelFundSource = org.mockito.Mockito.mock(GLAccount.class); + PaymentDetail paymentDetail = org.mockito.Mockito.mock(PaymentDetail.class); + PaymentType paymentType = org.mockito.Mockito.mock(PaymentType.class); + + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("1000")); + when(txn.getPaymentDetail()).thenReturn(paymentDetail); + when(paymentDetail.getPaymentType()).thenReturn(paymentType); + when(paymentType.getId()).thenReturn(5L); + when(allocation.getPrincipalPortion()).thenReturn(new BigDecimal("1000")); + when(allocation.getFeeChargesPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), eq(CashAccountsForLoan.FUND_SOURCE.getValue()), eq(5L))) + .thenReturn(paymentChannelFundSource); + + processor.postJournalEntriesForRepayment(loan, txn, allocation, false); + + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(paymentChannelFundSource), + eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("1000")), eq(paymentDetail)); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/accounting/WorkingCapitalLoanAccountingProcessor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/accounting/WorkingCapitalLoanAccountingProcessor.java new file mode 100644 index 00000000000..eb75886fd5b --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/accounting/WorkingCapitalLoanAccountingProcessor.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.accounting; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; + +public interface WorkingCapitalLoanAccountingProcessor { + + void postJournalEntriesForRepayment(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, + WorkingCapitalLoanTransactionAllocation allocation, boolean isChargedOff); + + void postReversalJournalEntries(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 0bd1d58d562..92a74c64876 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; +import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; import org.apache.fineract.portfolio.workingcapitalloan.data.RepaymentAmortizationData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; @@ -97,6 +98,7 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final InternalWorkingCapitalLoanPaymentService internalWorkingCapitalLoanPaymentService; private final CodeValueRepository codeValueRepository; private final BusinessEventNotifierService businessEventNotifierService; + private final WorkingCapitalLoanAccountingProcessor accountingProcessor; private final WorkingCapitalLoanTransactionRelationRepository relationRepository; @Override @@ -544,16 +546,17 @@ public CommandProcessingResult makeRepayment(final Long loanId, final JsonComman paymentDetail, transactionDate, classification, txnExternalId); this.transactionRepository.saveAndFlush(repaymentTransaction); - final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation - .forPrincipalAllocation(repaymentTransaction, transactionAmount); - this.allocationRepository.saveAndFlush(allocation); - final WorkingCapitalLoanBalance currentBalance = this.balanceRepository.findByWcLoan_Id(loan.getId()) .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); final BigDecimal outstandingBeforeRepayment = currentBalance.getPrincipalOutstanding() != null ? currentBalance.getPrincipalOutstanding() : BigDecimal.ZERO; final BigDecimal amountAppliedToOutstanding = transactionAmount.min(outstandingBeforeRepayment); + + final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation + .forPrincipalAllocation(repaymentTransaction, amountAppliedToOutstanding); + this.allocationRepository.saveAndFlush(allocation); + final RepaymentAmortizationData amortizationData = amortizationScheduleWriteService.applyRepayment(loan, transactionDate, amountAppliedToOutstanding); updateBalanceOnRepayment(loan, transactionAmount, amortizationData); @@ -582,6 +585,11 @@ public CommandProcessingResult makeRepayment(final Long loanId, final JsonComman createNote(noteText, loan); this.loanRepository.saveAndFlush(loan); + + if (loan.getLoanProduct().getAccountingRule().isCashBased()) { + accountingProcessor.postJournalEntriesForRepayment(loan, repaymentTransaction, allocation, false); + } + businessEventNotifierService .notifyPostBusinessEvent(new WorkingCapitalLoanRepaymentTransactionBusinessEvent(repaymentTransaction, loan.getId())); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java index 612f43c2020..6540eb92075 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java @@ -76,6 +76,8 @@ private WorkingCapitalLoanProductConstants() { public static final String transfersInSuspenseAccountIdParamName = "transfersInSuspenseAccountId"; public static final String deferredIncomeLiabilityAccountIdParamName = "deferredIncomeLiabilityAccountId"; public static final String incomeFromDiscountFeeAccountIdParamName = "incomeFromDiscountFeeAccountId"; + public static final String receivableFeeAccountIdParamName = "receivableFeeAccountId"; + public static final String receivablePenaltyAccountIdParamName = "receivablePenaltyAccountId"; public static final String incomeFromFeeAccountIdParamName = "incomeFromFeeAccountId"; public static final String incomeFromPenaltyAccountIdParamName = "incomeFromPenaltyAccountId"; public static final String incomeFromRecoveryAccountIdParamName = "incomeFromRecoveryAccountId"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java index ab38474ba2d..355e138cd0a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java @@ -168,6 +168,10 @@ private PostWorkingCapitalLoanProductsRequest() {} public Long chargeOffExpenseAccountId; @Schema(example = "18") public Long chargeOffFraudExpenseAccountId; + @Schema(example = "19") + public Long receivableFeeAccountId; + @Schema(example = "20") + public Long receivablePenaltyAccountId; public List paymentChannelToFundSourceMappings; public List feeToIncomeAccountMappings; public List penaltyToIncomeAccountMappings; @@ -628,6 +632,10 @@ private PutWorkingCapitalLoanProductsProductIdRequest() {} public Long chargeOffExpenseAccountId; @Schema(example = "18") public Long chargeOffFraudExpenseAccountId; + @Schema(example = "19") + public Long receivableFeeAccountId; + @Schema(example = "20") + public Long receivablePenaltyAccountId; public List paymentChannelToFundSourceMappings; public List feeToIncomeAccountMappings; public List penaltyToIncomeAccountMappings; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java index 1ec2570b34b..8b01f758933 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java @@ -105,6 +105,8 @@ public class WorkingCapitalLoanProductDataValidator { WorkingCapitalLoanProductConstants.transfersInSuspenseAccountIdParamName, // WorkingCapitalLoanProductConstants.deferredIncomeLiabilityAccountIdParamName, // WorkingCapitalLoanProductConstants.incomeFromDiscountFeeAccountIdParamName, // + WorkingCapitalLoanProductConstants.receivableFeeAccountIdParamName, // + WorkingCapitalLoanProductConstants.receivablePenaltyAccountIdParamName, // WorkingCapitalLoanProductConstants.incomeFromFeeAccountIdParamName, // WorkingCapitalLoanProductConstants.incomeFromPenaltyAccountIdParamName, // WorkingCapitalLoanProductConstants.incomeFromRecoveryAccountIdParamName, // @@ -614,6 +616,16 @@ private void validateAccountingRule(final JsonElement element, final DataValidat baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.incomeFromDiscountFeeAccountIdParamName) .value(incomeFromDiscountFeeAccountId).notNull().integerGreaterThanZero(); + final Long receivableFeeAccountId = this.fromApiJsonHelper + .extractLongNamed(WorkingCapitalLoanProductConstants.receivableFeeAccountIdParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.receivableFeeAccountIdParamName) + .value(receivableFeeAccountId).notNull().integerGreaterThanZero(); + + final Long receivablePenaltyAccountId = this.fromApiJsonHelper + .extractLongNamed(WorkingCapitalLoanProductConstants.receivablePenaltyAccountIdParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.receivablePenaltyAccountIdParamName) + .value(receivablePenaltyAccountId).notNull().integerGreaterThanZero(); + final Long incomeFromFeeAccountId = this.fromApiJsonHelper .extractLongNamed(WorkingCapitalLoanProductConstants.incomeFromFeeAccountIdParamName, element); baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.incomeFromFeeAccountIdParamName) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java index b7bec1c9cad..88e9a1cff23 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java @@ -61,6 +61,12 @@ public void saveCashBasedAccountMapping(final JsonElement element, final Long pr saveAccountMapping(element, LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), productId, CashAccountsForLoan.TRANSFERS_SUSPENSE.getValue(), GLAccountType.ASSET); + // assets (receivables) + saveAccountMapping(element, LoanProductAccountingParams.FEES_RECEIVABLE.getValue(), productId, + CashAccountsForLoan.FEES_RECEIVABLE.getValue(), GLAccountType.ASSET); + saveAccountMapping(element, LoanProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), productId, + CashAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), GLAccountType.ASSET); + // income (required) saveAccountMapping(element, LoanProductAccountingParams.INCOME_FROM_DISCOUNT_FEE.getValue(), productId, CashAccountsForLoan.INCOME_FROM_DISCOUNT_FEE.getValue(), GLAccountType.INCOME); @@ -115,6 +121,12 @@ public void handleChangesToCashBasedAccountMapping(final Long productId, final M mergeAccountMappingChanges(element, LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), productId, CashAccountsForLoan.TRANSFERS_SUSPENSE.getValue(), changes, GLAccountType.ASSET); + // assets (receivables) + mergeAccountMappingChanges(element, LoanProductAccountingParams.FEES_RECEIVABLE.getValue(), productId, + CashAccountsForLoan.FEES_RECEIVABLE.getValue(), changes, GLAccountType.ASSET); + mergeAccountMappingChanges(element, LoanProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), productId, + CashAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), changes, GLAccountType.ASSET); + // income mergeAccountMappingChanges(element, LoanProductAccountingParams.INCOME_FROM_DISCOUNT_FEE.getValue(), productId, CashAccountsForLoan.INCOME_FROM_DISCOUNT_FEE.getValue(), changes, GLAccountType.INCOME); @@ -160,6 +172,8 @@ public Map populateChangesForNewCashBasedMappingCreation(final J putChange(changes, element, LoanProductAccountingParams.FUND_SOURCE); putChange(changes, element, LoanProductAccountingParams.LOAN_PORTFOLIO); putChange(changes, element, LoanProductAccountingParams.TRANSFERS_SUSPENSE); + putChange(changes, element, LoanProductAccountingParams.FEES_RECEIVABLE); + putChange(changes, element, LoanProductAccountingParams.PENALTIES_RECEIVABLE); putChange(changes, element, LoanProductAccountingParams.INCOME_FROM_DISCOUNT_FEE); putChange(changes, element, LoanProductAccountingParams.INCOME_FROM_FEES); putChange(changes, element, LoanProductAccountingParams.INCOME_FROM_PENALTIES); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java index 20f84158fdd..c689b462e64 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java @@ -154,6 +154,10 @@ public Map fetchAccountMappingDetails(final Long wcLoanPr accountMappingDetails.put(LoanProductAccountingDataParams.INCOME_FROM_DISCOUNT_FEE.getValue(), glAccountData); case DEFERRED_INCOME_LIABILITY -> accountMappingDetails.put(LoanProductAccountingDataParams.DEFERRED_INCOME_LIABILITY.getValue(), glAccountData); + case FEES_RECEIVABLE -> + accountMappingDetails.put(LoanProductAccountingDataParams.FEES_RECEIVABLE.getValue(), glAccountData); + case PENALTIES_RECEIVABLE -> + accountMappingDetails.put(LoanProductAccountingDataParams.PENALTIES_RECEIVABLE.getValue(), glAccountData); case INCOME_FROM_FEES -> accountMappingDetails.put(LoanProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData); case INCOME_FROM_PENALTIES -> diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanRepaymentAccountingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanRepaymentAccountingTest.java new file mode 100644 index 00000000000..11402db9472 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanRepaymentAccountingTest.java @@ -0,0 +1,317 @@ +/** + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse; +import org.apache.fineract.client.models.JournalEntryTransactionItem; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest.AccountingRuleEnum; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignJournalEntryHelper; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests verifying that cash-based accounting journal entries are correctly created during Working Capital + * Loan repayment transactions. + */ +public class WorkingCapitalLoanRepaymentAccountingTest { + + private static final String CLEANUP_EMPTY_COMMAND_JSON = "{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\"}"; + + private final WorkingCapitalLoanHelper loanHelper = new WorkingCapitalLoanHelper(); + private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + private static Long createdClientId; + + // GL accounts for cash-based accounting + private static Account fundSourceAccount; + private static Account loanPortfolioAccount; + private static Account transfersSuspenseAccount; + private static Account incomeFromDiscountFeeAccount; + private static Account feesReceivableAccount; + private static Account penaltiesReceivableAccount; + private static Account incomeFromFeeAccount; + private static Account incomeFromPenaltyAccount; + private static Account incomeFromRecoveryAccount; + private static Account writeOffAccount; + private static Account overpaymentAccount; + private static Account deferredIncomeAccount; + + @BeforeAll + public static void setupAccounts() { + createdClientId = createClient(); + final FeignAccountHelper accountHelper = new FeignAccountHelper(FineractFeignClientHelper.getFineractFeignClient()); + fundSourceAccount = accountHelper.createLiabilityAccount("wcFundSource"); + loanPortfolioAccount = accountHelper.createAssetAccount("wcLoanPortfolio"); + transfersSuspenseAccount = accountHelper.createAssetAccount("wcTransfersSuspense"); + incomeFromDiscountFeeAccount = accountHelper.createIncomeAccount("wcIncomeDiscountFee"); + feesReceivableAccount = accountHelper.createAssetAccount("wcFeesReceivable"); + penaltiesReceivableAccount = accountHelper.createAssetAccount("wcPenaltiesReceivable"); + incomeFromFeeAccount = accountHelper.createIncomeAccount("wcIncomeFee"); + incomeFromPenaltyAccount = accountHelper.createIncomeAccount("wcIncomePenalty"); + incomeFromRecoveryAccount = accountHelper.createIncomeAccount("wcIncomeRecovery"); + writeOffAccount = accountHelper.createExpenseAccount("wcWriteOff"); + overpaymentAccount = accountHelper.createLiabilityAccount("wcOverpayment"); + deferredIncomeAccount = accountHelper.createLiabilityAccount("wcDeferredIncome"); + } + + @AfterEach + void cleanupEntities() { + for (final Long loanId : createdLoanIds) { + if (loanId == null) { + continue; + } + try { + loanHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be disbursed / client inactive / loan already removed) + } + try { + loanHelper.undoApprovalById(loanId, CLEANUP_EMPTY_COMMAND_JSON); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be approved / already removed) + } + try { + loanHelper.deleteById(loanId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may be in non-deletable state / already removed) + } + } + createdLoanIds.clear(); + for (final Long productId : createdProductIds) { + if (productId == null) { + continue; + } + try { + productHelper.deleteWorkingCapitalLoanProductById(productId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (product may be already removed) + } + } + createdProductIds.clear(); + } + + @Test + public void testRepaymentCreatesJournalEntriesPrincipalOnly() { + final Long productId = createCashBasedProduct(); + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + final Long loanId = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), approvedOnDate); + + final LocalDate repaymentDate = approvedOnDate.plusDays(1); + BusinessDateHelper.runAt(repaymentDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")), + () -> loanHelper.makeRepaymentByLoanId(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildRepaymentJson(repaymentDate, + BigDecimal.valueOf(3000), null, "partial repayment", 1, "repayment-account"))); + + // Verify loan status is still active (partial repayment) + final JsonObject loanData = JsonParser.parseString(loanHelper.retrieveById(loanId)).getAsJsonObject(); + assertEquals("loanStatusType.active", loanData.getAsJsonObject("status").get("code").getAsString()); + + // Verify journal entries: Dr Fund Source 3000, Cr Loan Portfolio 3000 + final Long repaymentTxnId = getRepaymentTransactionId(loanId); + assertNotNull(repaymentTxnId, "Expected a repayment transaction to exist"); + final List entries = getJournalEntriesForWCTransaction(repaymentTxnId); + assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); + + assertJournalEntry(entries, "DEBIT", fundSourceAccount, 3000.0); + assertJournalEntry(entries, "CREDIT", loanPortfolioAccount, 3000.0); + } + + @Test + public void testRepaymentWithOverpaymentCreatesJournalEntries() { + final Long productId = createCashBasedProduct(); + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + final Long loanId = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), approvedOnDate); + + final LocalDate repaymentDate = approvedOnDate.plusDays(1); + BusinessDateHelper.runAt(repaymentDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")), + () -> loanHelper.makeRepaymentByLoanId(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildRepaymentJson(repaymentDate, + BigDecimal.valueOf(5200), null, "overpayment repayment", 1, "repayment-account"))); + + // Verify loan status is overpaid + final JsonObject loanData = JsonParser.parseString(loanHelper.retrieveById(loanId)).getAsJsonObject(); + assertEquals("loanStatusType.overpaid", loanData.getAsJsonObject("status").get("code").getAsString()); + + // Verify journal entries: Dr Fund Source 5200, Cr Loan Portfolio 5000, Cr Overpayment 200 + final Long repaymentTxnId = getRepaymentTransactionId(loanId); + assertNotNull(repaymentTxnId, "Expected a repayment transaction to exist"); + final List entries = getJournalEntriesForWCTransaction(repaymentTxnId); + assertEquals(3, entries.size(), "Expected 3 journal entries (1 debit + 2 credits)"); + + assertJournalEntry(entries, "DEBIT", fundSourceAccount, 5200.0); + assertJournalEntry(entries, "CREDIT", loanPortfolioAccount, 5000.0); + assertJournalEntry(entries, "CREDIT", overpaymentAccount, 200.0); + } + + @Test + public void testFullRepaymentCreatesJournalEntries() { + final Long productId = createCashBasedProduct(); + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + final Long loanId = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), approvedOnDate); + + final LocalDate repaymentDate = approvedOnDate.plusDays(1); + BusinessDateHelper.runAt(repaymentDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")), + () -> loanHelper.makeRepaymentByLoanId(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildRepaymentJson(repaymentDate, + BigDecimal.valueOf(5000), null, "full payoff", 1, "repayment-account"))); + + // Verify loan status is closed + final JsonObject loanData = JsonParser.parseString(loanHelper.retrieveById(loanId)).getAsJsonObject(); + assertEquals("loanStatusType.closed.obligations.met", loanData.getAsJsonObject("status").get("code").getAsString()); + + // Verify journal entries: Dr Fund Source 5000, Cr Loan Portfolio 5000 + final Long repaymentTxnId = getRepaymentTransactionId(loanId); + assertNotNull(repaymentTxnId, "Expected a repayment transaction to exist"); + final List entries = getJournalEntriesForWCTransaction(repaymentTxnId); + assertEquals(2, entries.size(), "Expected 2 journal entries (1 debit + 1 credit)"); + + assertJournalEntry(entries, "DEBIT", fundSourceAccount, 5000.0); + assertJournalEntry(entries, "CREDIT", loanPortfolioAccount, 5000.0); + } + + @Test + public void testRepaymentWithNoAccountingCreatesNoJournalEntries() { + // Create product with NONE accounting rule + final String uniqueName = "WCL NoAcct " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + final Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + final Long loanId = createApprovedAndDisbursedLoan(productId, BigDecimal.valueOf(5000), approvedOnDate); + + final LocalDate repaymentDate = approvedOnDate.plusDays(1); + BusinessDateHelper.runAt(repaymentDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")), + () -> loanHelper.makeRepaymentByLoanId(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildRepaymentJson(repaymentDate, + BigDecimal.valueOf(3000), null, "no accounting repayment", 1, "repayment-account"))); + + // Verify no journal entries were created + final Long repaymentTxnId = getRepaymentTransactionId(loanId); + assertNotNull(repaymentTxnId, "Expected a repayment transaction to exist"); + final List entries = getJournalEntriesForWCTransaction(repaymentTxnId); + assertTrue(entries.isEmpty(), "Expected no journal entries for NONE accounting rule"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private Long createCashBasedProduct() { + final String uniqueName = "WCL Acct " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + final Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withAccountingRule(AccountingRuleEnum.CASH_BASED) + .withFundSourceAccountId(fundSourceAccount.getAccountID().longValue()) + .withLoanPortfolioAccountId(loanPortfolioAccount.getAccountID().longValue()) + .withTransfersInSuspenseAccountId(transfersSuspenseAccount.getAccountID().longValue()) + .withIncomeFromDiscountFeeAccountId(incomeFromDiscountFeeAccount.getAccountID().longValue()) + .withReceivableFeeAccountId(feesReceivableAccount.getAccountID().longValue()) + .withReceivablePenaltyAccountId(penaltiesReceivableAccount.getAccountID().longValue()) + .withIncomeFromFeeAccountId(incomeFromFeeAccount.getAccountID().longValue()) + .withIncomeFromPenaltyAccountId(incomeFromPenaltyAccount.getAccountID().longValue()) + .withIncomeFromRecoveryAccountId(incomeFromRecoveryAccount.getAccountID().longValue()) + .withWriteOffAccountId(writeOffAccount.getAccountID().longValue()) + .withOverpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue()) + .withDeferredIncomeLiabilityAccountId(deferredIncomeAccount.getAccountID().longValue()).build()).getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createApprovedAndDisbursedLoan(final Long productId, final BigDecimal principal, final LocalDate approvedOnDate) { + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder().withClientId(createdClientId) + .withProductId(productId).withPrincipal(principal).withPeriodPaymentRate(BigDecimal.ONE).buildSubmitJson()); + loanHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, principal, null)); + loanHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(approvedOnDate, principal)); + return loanId; + } + + private Long getRepaymentTransactionId(final Long loanId) { + final JsonArray content = JsonParser.parseString(loanHelper.retrieveTransactionsByLoanIdRaw(loanId)).getAsJsonObject() + .getAsJsonArray("content"); + for (int i = 0; i < content.size(); i++) { + final JsonObject txn = content.get(i).getAsJsonObject(); + final JsonObject txnType = txn.getAsJsonObject("type"); + if (txnType != null && "loanTransactionType.repayment".equals(txnType.get("code").getAsString())) { + return txn.get("id").getAsLong(); + } + } + return null; + } + + private List getJournalEntriesForWCTransaction(final Long wcTransactionId) { + final String transactionId = "WC" + wcTransactionId; + final FeignJournalEntryHelper journalHelper = new FeignJournalEntryHelper(FineractFeignClientHelper.getFineractFeignClient()); + final GetJournalEntriesTransactionIdResponse response = journalHelper.getJournalEntriesByTransactionId(transactionId); + if (response == null || response.getPageItems() == null) { + return List.of(); + } + return response.getPageItems(); + } + + private void assertJournalEntry(final List entries, final String expectedType, + final Account expectedAccount, final double expectedAmount) { + final boolean found = entries.stream().anyMatch(entry -> { + final boolean typeMatch = expectedType.equals(entry.getEntryType().getValue()); + final boolean accountMatch = expectedAccount.getAccountID().longValue() == entry.getGlAccountId(); + final boolean amountMatch = Double.compare(expectedAmount, entry.getAmount()) == 0; + return typeMatch && accountMatch && amountMatch; + }); + assertTrue(found, + "Expected journal entry: " + expectedType + " " + expectedAccount.getAccountID() + " amount=" + expectedAmount + + " not found in entries: " + entries.stream() + .map(e -> e.getEntryType().getValue() + " acct=" + e.getGlAccountId() + " amt=" + e.getAmount()).toList()); + } + + private static Long createClient() { + return ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + } + + private Long submitAndTrack(final String submitJson) { + final Long loanId = loanHelper.submit(submitJson); + createdLoanIds.add(loanId); + return loanId; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java index faf7bf77ce3..b14cebce1e9 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java @@ -41,6 +41,10 @@ public GetJournalEntriesTransactionIdResponse getJournalEntriesForLoan(Long loan return ok(() -> fineractClient.journalEntries().retrieveAllJournalEntries(Map.of("loanId", loanId))); } + public GetJournalEntriesTransactionIdResponse getJournalEntriesByTransactionId(String transactionId) { + return ok(() -> fineractClient.journalEntries().retrieveAllJournalEntries(Map.of("transactionId", transactionId))); + } + public void verifyJournalEntries(Long loanId, LoanTestData.Journal... expectedEntries) { GetJournalEntriesTransactionIdResponse journalEntries = getJournalEntriesForLoan(loanId); assertNotNull(journalEntries); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java index 29aee53bcc1..5971069caf2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java @@ -79,6 +79,20 @@ public class WorkingCapitalLoanProductTestBuilder { private AccountingRuleEnum accountingRule = DEFAULT_ACCOUNTING_RULE; private Long nearBreachId; + // GL account IDs for cash-based accounting + private Long fundSourceAccountId; + private Long loanPortfolioAccountId; + private Long transfersInSuspenseAccountId; + private Long incomeFromDiscountFeeAccountId; + private Long receivableFeeAccountId; + private Long receivablePenaltyAccountId; + private Long incomeFromFeeAccountId; + private Long incomeFromPenaltyAccountId; + private Long incomeFromRecoveryAccountId; + private Long writeOffAccountId; + private Long overpaymentLiabilityAccountId; + private Long deferredIncomeLiabilityAccountId; + public WorkingCapitalLoanProductTestBuilder withName(final String name) { this.name = name; return this; @@ -214,6 +228,66 @@ public WorkingCapitalLoanProductTestBuilder withAccountingRule(final AccountingR return this; } + public WorkingCapitalLoanProductTestBuilder withFundSourceAccountId(final Long fundSourceAccountId) { + this.fundSourceAccountId = fundSourceAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withLoanPortfolioAccountId(final Long loanPortfolioAccountId) { + this.loanPortfolioAccountId = loanPortfolioAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withTransfersInSuspenseAccountId(final Long transfersInSuspenseAccountId) { + this.transfersInSuspenseAccountId = transfersInSuspenseAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withIncomeFromDiscountFeeAccountId(final Long incomeFromDiscountFeeAccountId) { + this.incomeFromDiscountFeeAccountId = incomeFromDiscountFeeAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withReceivableFeeAccountId(final Long receivableFeeAccountId) { + this.receivableFeeAccountId = receivableFeeAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withReceivablePenaltyAccountId(final Long receivablePenaltyAccountId) { + this.receivablePenaltyAccountId = receivablePenaltyAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withIncomeFromFeeAccountId(final Long incomeFromFeeAccountId) { + this.incomeFromFeeAccountId = incomeFromFeeAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withIncomeFromPenaltyAccountId(final Long incomeFromPenaltyAccountId) { + this.incomeFromPenaltyAccountId = incomeFromPenaltyAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withIncomeFromRecoveryAccountId(final Long incomeFromRecoveryAccountId) { + this.incomeFromRecoveryAccountId = incomeFromRecoveryAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withWriteOffAccountId(final Long writeOffAccountId) { + this.writeOffAccountId = writeOffAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withOverpaymentLiabilityAccountId(final Long overpaymentLiabilityAccountId) { + this.overpaymentLiabilityAccountId = overpaymentLiabilityAccountId; + return this; + } + + public WorkingCapitalLoanProductTestBuilder withDeferredIncomeLiabilityAccountId(final Long deferredIncomeLiabilityAccountId) { + this.deferredIncomeLiabilityAccountId = deferredIncomeLiabilityAccountId; + return this; + } + public PostWorkingCapitalLoanProductsRequest build() { final PostWorkingCapitalLoanProductsRequest request = new PostWorkingCapitalLoanProductsRequest(); populateCommonFields(request); @@ -261,6 +335,18 @@ private void populateCommonFields(final PostWorkingCapitalLoanProductsRequest re request.setBreachId(this.breachId); request.setAccountingRule(this.accountingRule); request.setNearBreachId(this.nearBreachId); + request.setFundSourceAccountId(this.fundSourceAccountId); + request.setLoanPortfolioAccountId(this.loanPortfolioAccountId); + request.setTransfersInSuspenseAccountId(this.transfersInSuspenseAccountId); + request.setIncomeFromDiscountFeeAccountId(this.incomeFromDiscountFeeAccountId); + request.setReceivableFeeAccountId(this.receivableFeeAccountId); + request.setReceivablePenaltyAccountId(this.receivablePenaltyAccountId); + request.setIncomeFromFeeAccountId(this.incomeFromFeeAccountId); + request.setIncomeFromPenaltyAccountId(this.incomeFromPenaltyAccountId); + request.setIncomeFromRecoveryAccountId(this.incomeFromRecoveryAccountId); + request.setWriteOffAccountId(this.writeOffAccountId); + request.setOverpaymentLiabilityAccountId(this.overpaymentLiabilityAccountId); + request.setDeferredIncomeLiabilityAccountId(this.deferredIncomeLiabilityAccountId); request.setLocale("en_US"); request.setDateFormat("yyyy-MM-dd"); }