From 4ccb33a95e8bd8a7934bcdacbfd146664080db3b Mon Sep 17 00:00:00 2001 From: Andrii Kulminskyi Date: Tue, 5 Nov 2024 11:54:55 +0200 Subject: [PATCH] FINERACT-2113: Advanced Charge-off Expense Accounting - Add new "Advanced Accounting Rule" --- .../domain/ProductToGLAccountMapping.java | 7 +- .../ProductToGLAccountMappingRepository.java | 6 +- .../ProductToGLAccountMappingHelper.java | 139 +++++++++ ...ToGLAccountMappingReadPlatformService.java | 1 - ...avingsProductToGLAccountMappingHelper.java | 5 +- .../ShareProductToGLAccountMappingHelper.java | 5 +- .../common/AccountingConstants.java | 5 +- .../LoanProductToGLAccountMappingHelper.java | 16 +- .../api/LoanProductsApiResourceSwagger.java | 13 + .../loanproduct/data/LoanProductData.java | 3 + ...ccountMappingWritePlatformServiceImpl.java | 4 + .../api/LoanProductsApiResource.java | 3 +- .../LoanProductDataValidator.java | 63 +++- .../db/changelog/tenant/changelog-tenant.xml | 1 + ...e_off_reason_id_to_acc_product_mapping.xml | 40 +++ ...oanProductChargeOffReasonMappingsTest.java | 272 ++++++++++++++++++ .../common/loans/LoanProductTestBuilder.java | 16 ++ .../common/system/CodeHelper.java | 36 +++ 18 files changed, 621 insertions(+), 14 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0153_add_charge_off_reason_id_to_acc_product_mapping.xml create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductChargeOffReasonMappingsTest.java diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMapping.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMapping.java index 8dd35f89150..43680cb5b75 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMapping.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMapping.java @@ -63,10 +63,13 @@ public class ProductToGLAccountMapping extends AbstractPersistableCustom { @Column(name = "financial_account_type", nullable = true) private int financialAccountType; + @Column(name = "charge_off_reason_id", nullable = true) + private Long chargeOffReasonId; + public static ProductToGLAccountMapping createNew(final GLAccount glAccount, final Long productId, final int productType, - final int financialAccountType) { + final int financialAccountType, final Long chargeOffReasonId) { return new ProductToGLAccountMapping().setGlAccount(glAccount).setProductId(productId).setProductType(productType) - .setFinancialAccountType(financialAccountType); + .setFinancialAccountType(financialAccountType).setChargeOffReasonId(chargeOffReasonId); } } diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java index 1a7ecc8dfac..fa953838f7f 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java @@ -35,7 +35,7 @@ ProductToGLAccountMapping findProductIdAndProductTypeAndFinancialAccountTypeAndC @Param("productType") int productType, @Param("financialAccountType") int financialAccountType, @Param("chargeId") Long ChargeId); - @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.financialAccountType=:financialAccountType and mapping.paymentType is NULL and mapping.charge is NULL") + @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.financialAccountType=:financialAccountType and mapping.paymentType is NULL and mapping.charge is NULL and mapping.chargeOffReasonId is NULL") ProductToGLAccountMapping findCoreProductToFinAccountMapping(@Param("productId") Long productId, @Param("productType") int productType, @Param("financialAccountType") int financialAccountType); @@ -61,4 +61,8 @@ List findAllPenaltyToIncomeAccountMappings(@Param("pr @Param("productType") int productType); List findByProductIdAndProductType(Long productId, int productType); + + @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.chargeOffReasonId is not NULL") + List findAllChargesOffReasonsMappings(@Param("productId") Long productId, + @Param("productType") int productType); } diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java index f51ad5c578d..5c01b16706b 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; @@ -39,7 +40,11 @@ import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingInvalidException; import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.charge.domain.Charge; @@ -53,6 +58,7 @@ public class ProductToGLAccountMappingHelper { protected static final List ASSET_LIABILITY_TYPES = List.of(GLAccountType.ASSET, GLAccountType.LIABILITY); + private static final Integer GL_ACCOUNT_EXPENSE_TYPE = 5; protected final GLAccountRepository accountRepository; protected final ProductToGLAccountMappingRepository accountMappingRepository; @@ -60,6 +66,7 @@ public class ProductToGLAccountMappingHelper { private final ChargeRepositoryWrapper chargeRepositoryWrapper; protected final GLAccountRepositoryWrapper accountRepositoryWrapper; private final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper; + private final CodeValueRepository codeValueRepository; public void saveProductToAccountMapping(final JsonElement element, final String paramName, final Long productId, final int placeHolderTypeId, final GLAccountType expectedAccountType, final PortfolioProductType portfolioProductType) { @@ -194,6 +201,27 @@ public void saveChargesToGLAccountMappings(final JsonCommand command, final Json } } + public void saveChargeOffReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId, + final Map changes, final PortfolioProductType portfolioProductType) { + + final String arrayName = LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(); + final JsonArray chargeOffReasonToExpenseAccountMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(arrayName, element); + + if (chargeOffReasonToExpenseAccountMappingArray != null) { + if (changes != null) { + changes.put(arrayName, command.jsonFragment(arrayName)); + } + + for (int i = 0; i < chargeOffReasonToExpenseAccountMappingArray.size(); i++) { + final JsonObject jsonObject = chargeOffReasonToExpenseAccountMappingArray.get(i).getAsJsonObject(); + final Long reasonId = jsonObject.get(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue()).getAsLong(); + final Long expenseAccountId = jsonObject.get(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()).getAsLong(); + + saveChargeOffReasonToExpenseMapping(productId, reasonId, expenseAccountId, portfolioProductType); + } + } + } + /** * @param command * @param element @@ -356,6 +384,65 @@ public void updatePaymentChannelToFundSourceMappings(final JsonCommand command, } } + public void updateChargeOffReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId, + final Map changes, final PortfolioProductType portfolioProductType) { + + final List existingChargeOffReasonToGLAccountMappings = this.accountMappingRepository + .findAllChargesOffReasonsMappings(productId, portfolioProductType.getValue()); + final JsonArray chargeOffReasonToGLAccountMappingArray = this.fromApiJsonHelper + .extractJsonArrayNamed(LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(), element); + + final Map inputChargeOffReasonToGLAccountMap = new HashMap<>(); + + final Set existingChargeOffReasons = new HashSet<>(); + if (chargeOffReasonToGLAccountMappingArray != null) { + if (changes != null) { + changes.put(LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(), + command.jsonFragment(LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue())); + } + + for (int i = 0; i < chargeOffReasonToGLAccountMappingArray.size(); i++) { + final JsonObject jsonObject = chargeOffReasonToGLAccountMappingArray.get(i).getAsJsonObject(); + final Long expenseGlAccountId = jsonObject.get(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()).getAsLong(); + final Long chargeOffReasonCodeValueId = jsonObject + .get(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue()).getAsLong(); + inputChargeOffReasonToGLAccountMap.put(chargeOffReasonCodeValueId, expenseGlAccountId); + } + + // If input map is empty, delete all existing mappings + if (inputChargeOffReasonToGLAccountMap.isEmpty()) { + this.accountMappingRepository.deleteAllInBatch(existingChargeOffReasonToGLAccountMappings); + } else { + for (final ProductToGLAccountMapping existingChargeOffReasonToGLAccountMapping : existingChargeOffReasonToGLAccountMappings) { + final Long currentChargeOffReasonId = existingChargeOffReasonToGLAccountMapping.getChargeOffReasonId(); + if (currentChargeOffReasonId != null) { + existingChargeOffReasons.add(currentChargeOffReasonId); + // update existing mappings (if required) + if (inputChargeOffReasonToGLAccountMap.containsKey(currentChargeOffReasonId)) { + final Long newGLAccountId = inputChargeOffReasonToGLAccountMap.get(currentChargeOffReasonId); + if (!newGLAccountId.equals(existingChargeOffReasonToGLAccountMapping.getGlAccount().getId())) { + final Optional glAccount = accountRepository.findById(newGLAccountId); + if (glAccount.isPresent()) { + existingChargeOffReasonToGLAccountMapping.setGlAccount(glAccount.get()); + this.accountMappingRepository.saveAndFlush(existingChargeOffReasonToGLAccountMapping); + } + } + } // deleted payment type + else { + this.accountMappingRepository.delete(existingChargeOffReasonToGLAccountMapping); + } + } + } + + // only the newly added + for (Map.Entry entry : inputChargeOffReasonToGLAccountMap.entrySet().stream() + .filter(e -> !existingChargeOffReasons.contains(e.getKey())).toList()) { + saveChargeOffReasonToExpenseMapping(productId, entry.getKey(), entry.getValue(), portfolioProductType); + } + } + } + } + /** * @param productId * @@ -402,6 +489,24 @@ private void saveChargeToFundSourceMapping(final Long productId, final Long char this.accountMappingRepository.saveAndFlush(accountMapping); } + private void saveChargeOffReasonToExpenseMapping(final Long productId, final Long reasonId, final Long expenseAccountId, + final PortfolioProductType portfolioProductType) { + + final Optional glAccount = accountRepository.findById(expenseAccountId); + + final boolean reasonMappingExists = this.accountMappingRepository + .findAllChargesOffReasonsMappings(productId, portfolioProductType.getValue()).stream() + .anyMatch(mapping -> mapping.getChargeOffReasonId().equals(reasonId)); + + if (glAccount.isPresent() && !reasonMappingExists) { + final ProductToGLAccountMapping accountMapping = new ProductToGLAccountMapping().setGlAccount(glAccount.get()) + .setProductId(productId).setProductType(portfolioProductType.getValue()) + .setFinancialAccountType(CashAccountsForLoan.CHARGE_OFF_EXPENSE.getValue()).setChargeOffReasonId(reasonId); + + this.accountMappingRepository.saveAndFlush(accountMapping); + } + } + private List getAllowedAccountTypesForFeeMapping() { List allowedAccountTypes = new ArrayList<>(); allowedAccountTypes.add(GLAccountType.INCOME); @@ -455,4 +560,38 @@ public void deleteProductToGLAccountMapping(final Long loanProductId, final Port this.accountMappingRepository.deleteAllInBatch(productToGLAccountMappings); } } + + public void validateChargeOffMappingsInDatabase(final List mappings) { + final List validationErrors = new ArrayList<>(); + + for (JsonObject jsonObject : mappings) { + final Long expenseGlAccountId = this.fromApiJsonHelper + .extractLongNamed(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), jsonObject); + final Long chargeOffReasonCodeValueId = this.fromApiJsonHelper + .extractLongNamed(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(), jsonObject); + + // Validation: chargeOffReasonCodeValueId must exist in the database + CodeValue codeValue = this.codeValueRepository.findByCodeNameAndId("ChargeOffReasons", chargeOffReasonCodeValueId); + if (codeValue == null) { + validationErrors.add(ApiParameterError.parameterError("validation.msg.chargeoffreason.invalid", + "Charge-off reason with ID " + chargeOffReasonCodeValueId + " does not exist", + LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue())); + } + + // Validation: expenseGLAccountId must exist as a valid Expense GL account + final Optional glAccount = accountRepository.findById(expenseGlAccountId); + + if (glAccount.isEmpty() || !glAccount.get().getType().equals(GL_ACCOUNT_EXPENSE_TYPE)) { + validationErrors.add(ApiParameterError.parameterError("validation.msg.glaccount.not.found", + "GL Account with ID " + expenseGlAccountId + " does not exist or is not an Expense GL account", + LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue())); + + } + } + + // Throw all collected validation errors, if any + if (!validationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(validationErrors); + } + } } diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java index 47df148a8eb..612c23c1244 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java @@ -46,5 +46,4 @@ public interface ProductToGLAccountMappingReadPlatformService { List fetchPaymentTypeToFundSourceMappingsForShareProduct(Long productId); List fetchFeeToIncomeAccountMappingsForShareProduct(Long productId); - } diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java index 18b7da24ebf..903ae202546 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java @@ -29,6 +29,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccountType; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.PortfolioProductType; @@ -42,9 +43,9 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa public SavingsProductToGLAccountMappingHelper(final GLAccountRepository glAccountRepository, final ProductToGLAccountMappingRepository glAccountMappingRepository, final FromJsonHelper fromApiJsonHelper, final ChargeRepositoryWrapper chargeRepositoryWrapper, final GLAccountRepositoryWrapper accountRepositoryWrapper, - final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper) { + final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper, final CodeValueRepository codeValueRepository) { super(glAccountRepository, glAccountMappingRepository, fromApiJsonHelper, chargeRepositoryWrapper, accountRepositoryWrapper, - paymentTypeRepositoryWrapper); + paymentTypeRepositoryWrapper, codeValueRepository); } /*** diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java index b026b428bc6..b7314f693ac 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java @@ -28,6 +28,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccountType; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.PortfolioProductType; @@ -41,9 +42,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp public ShareProductToGLAccountMappingHelper(final GLAccountRepository glAccountRepository, final ProductToGLAccountMappingRepository glAccountMappingRepository, final FromJsonHelper fromApiJsonHelper, final ChargeRepositoryWrapper chargeRepositoryWrapper, final GLAccountRepositoryWrapper accountRepositoryWrapper, - final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper) { + final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper, final CodeValueRepository codeValueRepository) { super(glAccountRepository, glAccountMappingRepository, fromApiJsonHelper, chargeRepositoryWrapper, accountRepositoryWrapper, - paymentTypeRepositoryWrapper); + paymentTypeRepositoryWrapper, codeValueRepository); } /*** 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 669ad59dcbb..ce7a2ff9fb4 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 @@ -172,7 +172,10 @@ public enum LoanProductAccountingParams { INCOME_FROM_CHARGE_OFF_PENALTY("incomeFromChargeOffPenaltyAccountId"), // INCOME_FROM_GOODWILL_CREDIT_INTEREST("incomeFromGoodwillCreditInterestAccountId"), // INCOME_FROM_GOODWILL_CREDIT_FEES("incomeFromGoodwillCreditFeesAccountId"), // - INCOME_FROM_GOODWILL_CREDIT_PENALTY("incomeFromGoodwillCreditPenaltyAccountId"); // + INCOME_FROM_GOODWILL_CREDIT_PENALTY("incomeFromGoodwillCreditPenaltyAccountId"), // + CHARGE_OFF_REASONS_TO_EXPENSE("chargeOffReasonsToExpenseMappings"), // + EXPENSE_GL_ACCOUNT_ID("expenseGLAccountId"), // + CHARGE_OFF_REASON_CODE_VALUE_ID("chargeOffReasonCodeValueId"); // private final String value; diff --git a/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java b/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java index b40a343e0f0..46140441985 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java @@ -33,6 +33,7 @@ import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingInvalidException; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingHelper; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.PortfolioProductType; @@ -46,9 +47,9 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi public LoanProductToGLAccountMappingHelper(final GLAccountRepository glAccountRepository, final ProductToGLAccountMappingRepository glAccountMappingRepository, final FromJsonHelper fromApiJsonHelper, final ChargeRepositoryWrapper chargeRepositoryWrapper, final GLAccountRepositoryWrapper accountRepositoryWrapper, - final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper) { + final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper, final CodeValueRepository codeValueRepository) { super(glAccountRepository, glAccountMappingRepository, fromApiJsonHelper, chargeRepositoryWrapper, accountRepositoryWrapper, - paymentTypeRepositoryWrapper); + paymentTypeRepositoryWrapper, codeValueRepository); } /*** @@ -138,6 +139,16 @@ public void saveChargesToIncomeAccountMappings(final JsonCommand command, final saveChargesToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN, false); } + public void saveChargeOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId, + final Map changes) { + saveChargeOffReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN); + } + + public void updateChargeOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId, + final Map changes) { + updateChargeOffReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN); + } + public void updateChargesToIncomeAccountMappings(final JsonCommand command, final JsonElement element, final Long productId, final Map changes) { // update both fee and penalty charges @@ -387,5 +398,4 @@ private GLAccountType getGLAccountType(final JsonElement element, final String p } return gLAccountType; } - } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java index 5ecfb6cff49..e83786baaf1 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java @@ -246,6 +246,7 @@ private PostLoanProductsRequest() {} public Long incomeFromGoodwillCreditPenaltyAccountId; public List paymentChannelToFundSourceMappings; public List feeToIncomeAccountMappings; + public List chargeOffReasonsToExpenseMappings; public List penaltyToIncomeAccountMappings; // Multi Disburse @@ -1209,6 +1210,16 @@ private GetLoanPaymentChannelToFundSourceMappings() {} public Long fundSourceAccountId; } + static final class GetChargeOffReasonsToExpenseMappings { + + private GetChargeOffReasonsToExpenseMappings() {} + + @Schema(example = "1") + public Long chargeOffReasonCodeValueId; + @Schema(example = "12") + public Long expenseGLAccountId; + } + static final class GetLoanFeeToIncomeAccountMappings { private GetLoanFeeToIncomeAccountMappings() {} @@ -1319,6 +1330,7 @@ private GetLoanCharge() {} public GetLoanAccountingMappings accountingMappings; public Set paymentChannelToFundSourceMappings; public Set feeToIncomeAccountMappings; + public Set chargeOffReasonsToExpenseMappings; @Schema(example = "false") public Boolean isRatesEnabled; @Schema(example = "true") @@ -1577,6 +1589,7 @@ private PutLoanProductsProductIdRequest() {} public Long incomeFromChargeOffPenaltyAccountId; public List paymentChannelToFundSourceMappings; public List feeToIncomeAccountMappings; + public List chargeOffReasonsToExpenseMappings; public List penaltyToIncomeAccountMappings; @Schema(example = "false") public Boolean enableAccrualActivityPosting; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java index c32997cb667..dd1273338c5 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java @@ -151,6 +151,7 @@ public class LoanProductData implements Serializable { private Collection paymentChannelToFundSourceMappings; private Collection feeToIncomeAccountMappings; private Collection penaltyToIncomeAccountMappings; + private List chargeOffReasonsToExpenseMappings; private final boolean enableAccrualActivityPosting; // rates @@ -853,6 +854,7 @@ public LoanProductData(final Long id, final String name, final String shortName, this.paymentChannelToFundSourceMappings = null; this.feeToIncomeAccountMappings = null; this.penaltyToIncomeAccountMappings = null; + this.chargeOffReasonsToExpenseMappings = null; this.valueConditionTypeOptions = null; this.principalVariationsForBorrowerCycle = principalVariations; this.interestRateVariationsForBorrowerCycle = interestRateVariations; @@ -992,6 +994,7 @@ public LoanProductData(final LoanProductData productData, final Collection updateLoanProductToGLAccountMapping(final Long loanPr accountingRuleType); this.loanProductToGLAccountMappingHelper.updatePaymentChannelToFundSourceMappings(command, element, loanProductId, changes); this.loanProductToGLAccountMappingHelper.updateChargesToIncomeAccountMappings(command, element, loanProductId, changes); + this.loanProductToGLAccountMappingHelper.updateChargeOffReasonToExpenseAccountMappings(command, element, loanProductId, + changes); } return changes; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java index 7adcbdefd8d..b2ac2594297 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java @@ -160,7 +160,7 @@ public class LoanProductsApiResource { @Operation(summary = "Create a Loan Product", description = "Depending of the Accounting Rule (accountingRule) selected, additional fields with details of the appropriate Ledger Account identifiers would need to be passed in.\n" + "\n" + "Refer MifosX Accounting Specs Draft for more details regarding the significance of the selected accounting rule\n\n" + "Mandatory Fields: name, shortName, currencyCode, digitsAfterDecimal, inMultiplesOf, principal, numberOfRepayments, repaymentEvery, repaymentFrequencyType, interestRatePerPeriod, interestRateFrequencyType, amortizationType, interestType, interestCalculationPeriodType, transactionProcessingStrategyCode, accountingRule, isInterestRecalculationEnabled, daysInYearType, daysInMonthType\n\n" - + "Optional Fields: inArrearsTolerance, graceOnPrincipalPayment, graceOnInterestPayment, graceOnInterestCharged, graceOnArrearsAgeing, charges, paymentChannelToFundSourceMappings, feeToIncomeAccountMappings, penaltyToIncomeAccountMappings, includeInBorrowerCycle, useBorrowerCycle,principalVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, multiDisburseLoan,maxTrancheCount, outstandingLoanBalance,overdueDaysForNPA,holdGuaranteeFunds, principalThresholdForLastInstalment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, installmentAmountInMultiplesOf, allowAttributeOverrides, allowPartialPeriodInterestCalcualtion,dueDaysForRepaymentEvent,overDueDaysForRepaymentEvent,enableDownPayment,disbursedAmountPercentageDownPayment,enableAutoRepaymentForDownPayment,repaymentStartDateType\n\n" + + "Optional Fields: inArrearsTolerance, graceOnPrincipalPayment, graceOnInterestPayment, graceOnInterestCharged, graceOnArrearsAgeing, charges, paymentChannelToFundSourceMappings, feeToIncomeAccountMappings, penaltyToIncomeAccountMappings, chargeOffReasonsToExpenseMappings, includeInBorrowerCycle, useBorrowerCycle,principalVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, multiDisburseLoan,maxTrancheCount, outstandingLoanBalance,overdueDaysForNPA,holdGuaranteeFunds, principalThresholdForLastInstalment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, installmentAmountInMultiplesOf, allowAttributeOverrides, allowPartialPeriodInterestCalcualtion,dueDaysForRepaymentEvent,overDueDaysForRepaymentEvent,enableDownPayment,disbursedAmountPercentageDownPayment,enableAutoRepaymentForDownPayment,repaymentStartDateType\n\n" + "Additional Mandatory Fields for Cash(2) based accounting: fundSourceAccountId, loanPortfolioAccountId, interestOnLoanAccountId, incomeFromFeeAccountId, incomeFromPenaltyAccountId, writeOffAccountId, transfersInSuspenseAccountId, overpaymentLiabilityAccountId\n\n" + "Additional Mandatory Fields for periodic (3) and upfront (4)accrual accounting: fundSourceAccountId, loanPortfolioAccountId, interestOnLoanAccountId, incomeFromFeeAccountId, incomeFromPenaltyAccountId, writeOffAccountId, receivableInterestAccountId, receivableFeeAccountId, receivablePenaltyAccountId, transfersInSuspenseAccountId, overpaymentLiabilityAccountId\n\n" + "Additional Mandatory Fields if interest recalculation is enabled(true): interestRecalculationCompoundingMethod, rescheduleStrategyMethod, recalculationRestFrequencyType\n\n" @@ -336,6 +336,7 @@ private String getLoanProductDetails(Long productId, UriInfo uriInfo) { Collection paymentChannelToFundSourceMappings; Collection feeToGLAccountMappings; Collection penaltyToGLAccountMappings; + List chargeOffReasonsToExpenseMappings; if (loanProduct.hasAccountingEnabled()) { accountingMappings = this.accountMappingReadPlatformService.fetchAccountMappingDetailsForLoanProduct(productId, loanProduct.getAccountingRule().getId().intValue()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java index 3758e4e20ab..e0d45318ed1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -36,6 +37,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams; import org.apache.fineract.accounting.common.AccountingValidations; +import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingHelper; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; @@ -141,7 +143,9 @@ public final class LoanProductDataValidator { LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_INTEREST.getValue(), LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_FEES.getValue(), LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_PENALTY.getValue(), - LoanProductConstants.USE_BORROWER_CYCLE_PARAMETER_NAME, + LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(), + LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), + LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(), LoanProductConstants.USE_BORROWER_CYCLE_PARAMETER_NAME, LoanProductConstants.PRINCIPAL_VARIATIONS_FOR_BORROWER_CYCLE_PARAMETER_NAME, LoanProductConstants.INTEREST_RATE_VARIATIONS_FOR_BORROWER_CYCLE_PARAMETER_NAME, LoanProductConstants.NUMBER_OF_REPAYMENT_VARIATIONS_FOR_BORROWER_CYCLE_PARAMETER_NAME, LoanProductConstants.SHORT_NAME, @@ -195,6 +199,7 @@ public final class LoanProductDataValidator { private final LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory; private final AdvancedPaymentAllocationsJsonParser advancedPaymentAllocationsJsonParser; private final AdvancedPaymentAllocationsValidator advancedPaymentAllocationsValidator; + private final ProductToGLAccountMappingHelper productToGLAccountMappingHelper; public void validateForCreate(final JsonCommand command) { String json = command.json(); @@ -718,6 +723,7 @@ public void validateForCreate(final JsonCommand command) { validatePaymentChannelFundSourceMappings(baseDataValidator, element); validateChargeToIncomeAccountMappings(baseDataValidator, element); + validateChargeOffToExpenseMappings(baseDataValidator, element); } @@ -1791,6 +1797,7 @@ public void validateForUpdate(final JsonCommand command, final LoanProduct loanP validatePaymentChannelFundSourceMappings(baseDataValidator, element); validateChargeToIncomeAccountMappings(baseDataValidator, element); + validateChargeOffToExpenseMappings(baseDataValidator, element); validateMinMaxConstraints(element, baseDataValidator, loanProduct); @@ -1964,6 +1971,60 @@ private void validateChargeToIncomeAccountMappings(final DataValidatorBuilder ba } } + private void validateChargeOffToExpenseMappings(final DataValidatorBuilder baseDataValidator, final JsonElement element) { + String parameterName = LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(); + + if (this.fromApiJsonHelper.parameterExists(parameterName, element)) { + final JsonArray chargeOffToExpenseMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(parameterName, element); + if (chargeOffToExpenseMappingArray != null && chargeOffToExpenseMappingArray.size() > 0) { + Map> chargeOffReasonToAccounts = new HashMap<>(); + List processedMappings = new ArrayList<>(); // Collect processed mappings for the new method + + int i = 0; + do { + final JsonObject jsonObject = chargeOffToExpenseMappingArray.get(i).getAsJsonObject(); + final Long expenseGlAccountId = this.fromApiJsonHelper + .extractLongNamed(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), jsonObject); + final Long chargeOffReasonCodeValueId = this.fromApiJsonHelper + .extractLongNamed(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(), jsonObject); + + // Validate parameters locally + baseDataValidator.reset() + .parameter(parameterName + OPENING_SQUARE_BRACKET + i + CLOSING_SQUARE_BRACKET + DOT + + LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()) + .value(expenseGlAccountId).notNull().integerGreaterThanZero(); + baseDataValidator.reset() + .parameter(parameterName + OPENING_SQUARE_BRACKET + i + CLOSING_SQUARE_BRACKET + DOT + + LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue()) + .value(chargeOffReasonCodeValueId).notNull().integerGreaterThanZero(); + + // Handle duplicate charge-off reason and GL Account validation + chargeOffReasonToAccounts.putIfAbsent(chargeOffReasonCodeValueId, new HashSet<>()); + Set associatedAccounts = chargeOffReasonToAccounts.get(chargeOffReasonCodeValueId); + + if (associatedAccounts.contains(expenseGlAccountId)) { + baseDataValidator.reset().parameter(parameterName + OPENING_SQUARE_BRACKET + i + CLOSING_SQUARE_BRACKET) + .failWithCode("duplicate.chargeOffReason.and.glAccount"); + } + associatedAccounts.add(expenseGlAccountId); + + if (associatedAccounts.size() > 1) { + baseDataValidator.reset().parameter(parameterName + OPENING_SQUARE_BRACKET + i + CLOSING_SQUARE_BRACKET) + .failWithCode("multiple.glAccounts.for.chargeOffReason"); + } + + // Collect mapping for additional validations + processedMappings.add(jsonObject); + + i++; + } while (i < chargeOffToExpenseMappingArray.size()); + + // Call the new validation method for additional checks + productToGLAccountMappingHelper.validateChargeOffMappingsInDatabase(processedMappings); + } + } + } + public void validateMinMaxConstraints(final JsonElement element, final DataValidatorBuilder baseDataValidator, final LoanProduct loanProduct) { validatePrincipalMinMaxConstraint(element, loanProduct, baseDataValidator); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 7b79a6754f1..2a0d2d0c32e 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -171,4 +171,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0153_add_charge_off_reason_id_to_acc_product_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0153_add_charge_off_reason_id_to_acc_product_mapping.xml new file mode 100644 index 00000000000..5002a38bbc0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0153_add_charge_off_reason_id_to_acc_product_mapping.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductChargeOffReasonMappingsTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductChargeOffReasonMappingsTest.java new file mode 100644 index 00000000000..95d8a735cbc --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductChargeOffReasonMappingsTest.java @@ -0,0 +1,272 @@ +/** + * 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.apache.fineract.integrationtests.common.funds.FundsResourceHandler.createFund; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import org.apache.fineract.client.models.AllowAttributeOverrides; +import org.apache.fineract.client.models.ChargeData; +import org.apache.fineract.client.models.ChargeToGLAccountMapper; +import org.apache.fineract.client.models.GetChargeOffReasonsToExpenseMappings; +import org.apache.fineract.client.models.GetLoanFeeToIncomeAccountMappings; +import org.apache.fineract.client.models.GetLoanPaymentChannelToFundSourceMappings; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PutLoanProductsProductIdRequest; +import org.apache.fineract.client.util.CallFailedRuntimeException; +import org.apache.fineract.integrationtests.common.BusinessStepHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper; +import org.apache.fineract.integrationtests.common.system.CodeHelper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(LoanTestLifecycleExtension.class) +public class LoanProductChargeOffReasonMappingsTest extends BaseLoanIntegrationTest { + + private static final String CODE_VALUE_NAME = "ChargeOffReasons"; + + private static ResponseSpecification responseSpec; + private static RequestSpecification requestSpec; + private static LoanTransactionHelper loanTransactionHelper; + + @BeforeAll + public static void setup() { + Utils.initializeRESTAssured(); + requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + requestSpec.header("Fineract-Platform-TenantId", "default"); + responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + loanTransactionHelper = new LoanTransactionHelper(requestSpec, responseSpec); + BusinessStepHelper businessStepHelper = new BusinessStepHelper(); + // setup COB Business Steps to prevent test failing due other integration test configurations + businessStepHelper.updateSteps("LOAN_CLOSE_OF_BUSINESS", "APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", + "CHECK_LOAN_REPAYMENT_DUE", "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", + "EXTERNAL_ASSET_OWNER_TRANSFER", "ACCRUAL_ACTIVITY_POSTING"); + } + + @Test + public void testCreateLoanProductWithValidChargeOffReason() { + final String creationBusinessDay = "15 January 2023"; + runAt(creationBusinessDay, () -> { + Integer chargeOffReasons = createChargeOffReason(); + Long localLoanProductId = loanTransactionHelper.createLoanProduct(loanProductsRequest(Long.valueOf(chargeOffReasons), 15L)) + .getResourceId(); + + Assertions.assertNotNull(localLoanProductId); + }); + } + + @Test + public void testUpdateLoanProductWithValidChargeOffReason() { + final String creationBusinessDay = "15 January 2023"; + runAt(creationBusinessDay, () -> { + Integer chargeOffReasons = createChargeOffReason(); + List chargeOffReasonsToExpenseMappings = new ArrayList<>(); + GetChargeOffReasonsToExpenseMappings getChargeOffReasonsToExpenseMappings = new GetChargeOffReasonsToExpenseMappings(); + getChargeOffReasonsToExpenseMappings.setChargeOffReasonCodeValueId(Long.valueOf(chargeOffReasons)); + getChargeOffReasonsToExpenseMappings.setExpenseGLAccountId(15L); + chargeOffReasonsToExpenseMappings.add(getChargeOffReasonsToExpenseMappings); + + Long localLoanProductId = loanTransactionHelper.updateLoanProduct(1L, + new PutLoanProductsProductIdRequest().locale("en").chargeOffReasonsToExpenseMappings(chargeOffReasonsToExpenseMappings)) + .getResourceId(); + + Assertions.assertNotNull(localLoanProductId); + }); + } + + @Test + public void testCreateLoanProductWithInvalidGLAccount() { + final String creationBusinessDay = "15 January 2023"; + runAt(creationBusinessDay, () -> { + try { + Integer chargeOffReasons = createChargeOffReason(); + loanTransactionHelper.createLoanProduct(loanProductsRequest(Long.valueOf(chargeOffReasons), 9999L)); + } catch (CallFailedRuntimeException e) { + Assertions.assertTrue(e.getMessage().contains("validation.msg.glaccount.not.found")); + } + }); + } + + @Test + public void testCreateLoanProductWithInvalidChargeOffReason() { + final String creationBusinessDay = "15 January 2023"; + runAt(creationBusinessDay, () -> { + try { + loanTransactionHelper.createLoanProduct(loanProductsRequest(1L, 12L)); + } catch (CallFailedRuntimeException e) { + Assertions.assertTrue(e.getMessage().contains("validation.msg.chargeoffreason.invalid")); + } + }); + } + + private PostLoanProductsRequest loanProductsRequest(Long chargeOffReasonId, Long glAccountId) { + String name = Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_", 6); + String shortName = Utils.uniqueRandomStringGenerator("", 4); + + List principalVariationsForBorrowerCycle = new ArrayList<>(); + List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); + List interestRateVariationsForBorrowerCycle = new ArrayList<>(); + List charges = new ArrayList<>(); + List penaltyToIncomeAccountMappings = new ArrayList<>(); + List feeToIncomeAccountMappings = new ArrayList<>(); + + List chargeOffReasonsToExpenseMappings = new ArrayList<>(); + GetChargeOffReasonsToExpenseMappings getChargeOffReasonsToExpenseMappings = new GetChargeOffReasonsToExpenseMappings(); + getChargeOffReasonsToExpenseMappings.setChargeOffReasonCodeValueId(chargeOffReasonId); + getChargeOffReasonsToExpenseMappings.setExpenseGLAccountId(glAccountId); + chargeOffReasonsToExpenseMappings.add(getChargeOffReasonsToExpenseMappings); + + List paymentChannelToFundSourceMappings = new ArrayList<>(); + GetLoanPaymentChannelToFundSourceMappings loanPaymentChannelToFundSourceMappings = new GetLoanPaymentChannelToFundSourceMappings(); + loanPaymentChannelToFundSourceMappings.fundSourceAccountId(fundSource.getAccountID().longValue()); + loanPaymentChannelToFundSourceMappings.paymentTypeId(1L); + paymentChannelToFundSourceMappings.add(loanPaymentChannelToFundSourceMappings); + + final Integer fundId = createFund(requestSpec, responseSpec); + Assertions.assertNotNull(fundId); + + final Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec); + Assertions.assertNotNull(delinquencyBucketId); + + return new PostLoanProductsRequest()// + .name(name)// + .enableAccrualActivityPosting(true)// + .shortName(shortName)// + .description( + "LP1 with 12% DECLINING BALANCE interest, interest period: Daily, Interest recalculation-Daily, Compounding:none")// + .fundId(fundId.longValue())// + .startDate(null)// + .closeDate(null)// + .includeInBorrowerCycle(false)// + .currencyCode("EUR")// + .digitsAfterDecimal(2)// + .inMultiplesOf(1)// + .installmentAmountInMultiplesOf(1)// + .useBorrowerCycle(false)// + .minPrincipal(100.0)// + .principal(1000.0)// + .maxPrincipal(10000.0)// + .minNumberOfRepayments(1)// + .numberOfRepayments(1)// + .maxNumberOfRepayments(30)// + .isLinkedToFloatingInterestRates(false)// + .minInterestRatePerPeriod(0.0)// + .interestRatePerPeriod(12.0)// + .maxInterestRatePerPeriod(30.0)// + .interestRateFrequencyType(3)// + .repaymentEvery(30)// + .repaymentFrequencyType(0L)// + .principalVariationsForBorrowerCycle(principalVariationsForBorrowerCycle)// + .numberOfRepaymentVariationsForBorrowerCycle(numberOfRepaymentVariationsForBorrowerCycle)// + .interestRateVariationsForBorrowerCycle(interestRateVariationsForBorrowerCycle)// + .amortizationType(1)// + .interestType(0)// + .isEqualAmortization(false)// + .interestCalculationPeriodType(0)// + .transactionProcessingStrategyCode("mifos-standard-strategy")// + .daysInYearType(1)// + .daysInMonthType(1)// + .canDefineInstallmentAmount(true)// + .graceOnArrearsAgeing(3)// + .overdueDaysForNPA(179)// + .accountMovesOutOfNPAOnlyOnArrearsCompletion(false)// + .principalThresholdForLastInstallment(50)// + .allowVariableInstallments(false)// + .canUseForTopup(false)// + .holdGuaranteeFunds(false)// + .multiDisburseLoan(false)// + .allowAttributeOverrides(new AllowAttributeOverrides()// + .amortizationType(true)// + .interestType(true)// + .transactionProcessingStrategyCode(true)// + .interestCalculationPeriodType(true)// + .inArrearsTolerance(true)// + .repaymentEvery(true)// + .graceOnPrincipalAndInterestPayment(true)// + .graceOnArrearsAgeing(true)) + .outstandingLoanBalance(10000.0)// + .charges(charges)// + .accountingRule(3)// + + .fundSourceAccountId(suspenseAccount.getAccountID().longValue())// + .loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue())// + .transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())// + .interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue())// + .incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue())// + .incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue())// + .incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue())// + .writeOffAccountId(writtenOffAccount.getAccountID().longValue())// + .overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())// + .receivableInterestAccountId(interestReceivableAccount.getAccountID().longValue())// + .receivableFeeAccountId(feeReceivableAccount.getAccountID().longValue())// + .receivablePenaltyAccountId(penaltyReceivableAccount.getAccountID().longValue())// + .goodwillCreditAccountId(goodwillExpenseAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditFeesAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffFeesAccountId(feeChargeOffAccount.getAccountID().longValue())// + .chargeOffExpenseAccountId(chargeOffExpenseAccount.getAccountID().longValue())// + .chargeOffFraudExpenseAccountId(chargeOffFraudExpenseAccount.getAccountID().longValue())// + .incomeFromChargeOffPenaltyAccountId(penaltyChargeOffAccount.getAccountID().longValue())// + + .dateFormat("dd MMMM yyyy")// + .locale("en")// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .delinquencyBucketId(delinquencyBucketId.longValue())// + .paymentChannelToFundSourceMappings(paymentChannelToFundSourceMappings)// + .penaltyToIncomeAccountMappings(penaltyToIncomeAccountMappings)// + .chargeOffReasonsToExpenseMappings(chargeOffReasonsToExpenseMappings).feeToIncomeAccountMappings(feeToIncomeAccountMappings)// + .isInterestRecalculationEnabled(true)// + .preClosureInterestCalculationStrategy(1)// + .rescheduleStrategyMethod(3)// + .interestRecalculationCompoundingMethod(0)// + .recalculationRestFrequencyType(2)// + .recalculationRestFrequencyInterval(1)// + .allowPartialPeriodInterestCalcualtion(false);// + } + + private Integer createChargeOffReason() { + Integer chargeOffReasonId; + HashMap codes = CodeHelper.getCodeByName(requestSpec, responseSpec, CODE_VALUE_NAME); + if (codes.isEmpty()) { + CodeHelper.createCode(requestSpec, responseSpec, CODE_VALUE_NAME, ""); + } + codes = CodeHelper.getCodeByName(requestSpec, responseSpec, CODE_VALUE_NAME); + Integer codeId = (Integer) codes.get("id"); + HashMap codeValues = CodeHelper.getOrCreateCodeValueByCodeIdAndCodeName(requestSpec, responseSpec, codeId, + CODE_VALUE_NAME, 1); + chargeOffReasonId = (Integer) codeValues.get("id"); + return chargeOffReasonId; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java index e9439c25922..75624a8c598 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java @@ -107,6 +107,7 @@ public class LoanProductTestBuilder { private List> feeToIncomeAccountMappings = null; private List> penaltyToIncomeAccountMappings = null; + private List> chargeOffReasonsToExpenseMappings = null; private Account feeAndPenaltyAssetAccount; private Boolean multiDisburseLoan = false; @@ -303,6 +304,10 @@ public HashMap build(final String chargeId, final Integer delinq map.put("penaltyToIncomeAccountMappings", this.penaltyToIncomeAccountMappings); } + if (this.chargeOffReasonsToExpenseMappings != null) { + map.put("chargeOffReasonsToExpenseMappings", this.chargeOffReasonsToExpenseMappings); + } + if (this.dueDaysForRepaymentEvent != null) { map.put("dueDaysForRepaymentEvent", this.dueDaysForRepaymentEvent); } @@ -796,6 +801,17 @@ public LoanProductTestBuilder withSupportedInterestRefundTypes(String... refundT return this; } + public LoanProductTestBuilder withChargeOffReasonsToExpenseMappings(final Long reasonId, final Long accountId) { + if (this.chargeOffReasonsToExpenseMappings == null) { + this.chargeOffReasonsToExpenseMappings = new ArrayList<>(); + } + Map newMap = new HashMap<>(); + newMap.put("chargeOffReasonCodeValueId", reasonId); + newMap.put("expenseGLAccountId", accountId); + this.chargeOffReasonsToExpenseMappings.add(newMap); + return this; + } + public String getTransactionProcessingStrategyCode() { return transactionProcessingStrategyCode; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java index 6eed20b579d..2f0cce3635c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/system/CodeHelper.java @@ -93,6 +93,35 @@ public static HashMap getCodeByName(final RequestSpecification r return code; } + public static HashMap getOrCreateCodeValueByCodeIdAndCodeName(final RequestSpecification requestSpec, + final ResponseSpecification responseSpec, final Integer codeId, final String codeName, final Integer position) { + + ArrayList> allCodeValues = CodeHelper.getAllCodeValuesByCodeId(requestSpec, responseSpec, codeId); + HashMap codesByName = filterCodesByName(allCodeValues, codeName); + + if (codesByName.isEmpty()) { + CodeHelper.createCodeValue(requestSpec, responseSpec, codeId, codeName, position); + allCodeValues = CodeHelper.getAllCodeValuesByCodeId(requestSpec, responseSpec, codeId); + } + + return filterCodesByName(allCodeValues, codeName); + } + + private static HashMap filterCodesByName(ArrayList> allCodeValues, String codeName) { + final HashMap codes = new HashMap<>(); + + for (HashMap map : allCodeValues) { + String name = (String) map.get("name"); + if (name.equals(codeName)) { + codes.put("id", map.get("id")); + codes.put("name", map.get("name")); + break; + } + } + + return codes; + } + public static HashMap retrieveOrCreateCodeValue(Integer codeId, final RequestSpecification requestSpec, final ResponseSpecification responseSpec) { Integer codeValueId = null; @@ -119,6 +148,13 @@ public static ArrayList> getAllCodes(final RequestSpecif } + public static ArrayList> getAllCodeValuesByCodeId(final RequestSpecification requestSpec, + final ResponseSpecification responseSpec, final Integer codeId) { + + return Utils.performServerGet(requestSpec, responseSpec, + CODE_VALUE_URL.replace("[codeId]", codeId.toString()) + "?" + Utils.TENANT_IDENTIFIER, ""); + } + public static Object getSystemDefinedCodes(final RequestSpecification requestSpec, final ResponseSpecification responseSpec) { final String getResponse = given().spec(requestSpec).expect().spec(responseSpec).when()