This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 4ccb33a95 FINERACT-2113: Advanced Charge-off Expense Accounting - Add
new "Advanced Accounting Rule"
4ccb33a95 is described below
commit 4ccb33a95e8bd8a7934bcdacbfd146664080db3b
Author: Andrii Kulminskyi <[email protected]>
AuthorDate: Tue Nov 5 11:54:55 2024 +0200
FINERACT-2113: Advanced Charge-off Expense Accounting - Add new "Advanced
Accounting Rule"
---
.../domain/ProductToGLAccountMapping.java | 7 +-
.../ProductToGLAccountMappingRepository.java | 6 +-
.../service/ProductToGLAccountMappingHelper.java | 139 +++++++++++
...oductToGLAccountMappingReadPlatformService.java | 1 -
.../SavingsProductToGLAccountMappingHelper.java | 5 +-
.../ShareProductToGLAccountMappingHelper.java | 5 +-
.../accounting/common/AccountingConstants.java | 5 +-
.../LoanProductToGLAccountMappingHelper.java | 16 +-
.../api/LoanProductsApiResourceSwagger.java | 13 +
.../loanproduct/data/LoanProductData.java | 3 +
...ToGLAccountMappingWritePlatformServiceImpl.java | 4 +
.../loanproduct/api/LoanProductsApiResource.java | 3 +-
.../serialization/LoanProductDataValidator.java | 63 ++++-
.../db/changelog/tenant/changelog-tenant.xml | 1 +
...charge_off_reason_id_to_acc_product_mapping.xml | 40 +++
.../LoanProductChargeOffReasonMappingsTest.java | 272 +++++++++++++++++++++
.../common/loans/LoanProductTestBuilder.java | 16 ++
.../integrationtests/common/system/CodeHelper.java | 36 +++
18 files changed, 621 insertions(+), 14 deletions(-)
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 8dd35f891..43680cb5b 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<Long> {
@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 1a7ecc8df..fa953838f 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 @@ public interface ProductToGLAccountMappingRepository
@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 @@ public interface ProductToGLAccountMappingRepository
@Param("productType") int productType);
List<ProductToGLAccountMapping> 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<ProductToGLAccountMapping>
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 f51ad5c57..5c01b1670 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.HashSet;
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.ProductToGL
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 @@ import org.springframework.stereotype.Component;
public class ProductToGLAccountMappingHelper {
protected static final List<GLAccountType> 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 class ProductToGLAccountMappingHelper {
}
}
+ public void saveChargeOffReasonToGLAccountMappings(final JsonCommand
command, final JsonElement element, final Long productId,
+ final Map<String, Object> 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 class ProductToGLAccountMappingHelper {
}
}
+ public void updateChargeOffReasonToGLAccountMappings(final JsonCommand
command, final JsonElement element, final Long productId,
+ final Map<String, Object> changes, final PortfolioProductType
portfolioProductType) {
+
+ final List<ProductToGLAccountMapping>
existingChargeOffReasonToGLAccountMappings = this.accountMappingRepository
+ .findAllChargesOffReasonsMappings(productId,
portfolioProductType.getValue());
+ final JsonArray chargeOffReasonToGLAccountMappingArray =
this.fromApiJsonHelper
+
.extractJsonArrayNamed(LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(),
element);
+
+ final Map<Long, Long> inputChargeOffReasonToGLAccountMap = new
HashMap<>();
+
+ final Set<Long> 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> 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<Long, Long> entry :
inputChargeOffReasonToGLAccountMap.entrySet().stream()
+ .filter(e ->
!existingChargeOffReasons.contains(e.getKey())).toList()) {
+ saveChargeOffReasonToExpenseMapping(productId,
entry.getKey(), entry.getValue(), portfolioProductType);
+ }
+ }
+ }
+ }
+
/**
* @param productId
*
@@ -402,6 +489,24 @@ public class ProductToGLAccountMappingHelper {
this.accountMappingRepository.saveAndFlush(accountMapping);
}
+ private void saveChargeOffReasonToExpenseMapping(final Long productId,
final Long reasonId, final Long expenseAccountId,
+ final PortfolioProductType portfolioProductType) {
+
+ final Optional<GLAccount> 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<GLAccountType> getAllowedAccountTypesForFeeMapping() {
List<GLAccountType> allowedAccountTypes = new ArrayList<>();
allowedAccountTypes.add(GLAccountType.INCOME);
@@ -455,4 +560,38 @@ public class ProductToGLAccountMappingHelper {
this.accountMappingRepository.deleteAllInBatch(productToGLAccountMappings);
}
}
+
+ public void validateChargeOffMappingsInDatabase(final List<JsonObject>
mappings) {
+ final List<ApiParameterError> 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> 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 47df148a8..612c23c12 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<PaymentTypeToGLAccountMapper>
fetchPaymentTypeToFundSourceMappingsForShareProduct(Long productId);
List<ChargeToGLAccountMapper>
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 18b7da24e..903ae2025 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.GLAccountRepository;
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 b026b428b..b7314f693 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.GLAccountRepository;
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 669ad59dc..ce7a2ff9f 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 final class AccountingConstants {
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 b40a343e0..461404419 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.glaccount.domain.GLAccountType;
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 class LoanProductToGLAccountMappingHelper extends
ProductToGLAccountMappi
saveChargesToGLAccountMappings(command, element, productId, changes,
PortfolioProductType.LOAN, false);
}
+ public void saveChargeOffReasonToExpenseAccountMappings(final JsonCommand
command, final JsonElement element, final Long productId,
+ final Map<String, Object> changes) {
+ saveChargeOffReasonToGLAccountMappings(command, element, productId,
changes, PortfolioProductType.LOAN);
+ }
+
+ public void updateChargeOffReasonToExpenseAccountMappings(final
JsonCommand command, final JsonElement element, final Long productId,
+ final Map<String, Object> changes) {
+ updateChargeOffReasonToGLAccountMappings(command, element, productId,
changes, PortfolioProductType.LOAN);
+ }
+
public void updateChargesToIncomeAccountMappings(final JsonCommand
command, final JsonElement element, final Long productId,
final Map<String, Object> changes) {
// update both fee and penalty charges
@@ -387,5 +398,4 @@ public class LoanProductToGLAccountMappingHelper extends
ProductToGLAccountMappi
}
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 5ecfb6cff..e83786baa 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 @@ final class LoanProductsApiResourceSwagger {
public Long incomeFromGoodwillCreditPenaltyAccountId;
public
List<GetLoanProductsProductIdResponse.GetLoanPaymentChannelToFundSourceMappings>
paymentChannelToFundSourceMappings;
public
List<GetLoanProductsProductIdResponse.GetLoanFeeToIncomeAccountMappings>
feeToIncomeAccountMappings;
+ public
List<GetLoanProductsProductIdResponse.GetChargeOffReasonsToExpenseMappings>
chargeOffReasonsToExpenseMappings;
public List<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
// Multi Disburse
@@ -1209,6 +1210,16 @@ final class LoanProductsApiResourceSwagger {
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 @@ final class LoanProductsApiResourceSwagger {
public GetLoanAccountingMappings accountingMappings;
public Set<GetLoanPaymentChannelToFundSourceMappings>
paymentChannelToFundSourceMappings;
public Set<GetLoanFeeToIncomeAccountMappings>
feeToIncomeAccountMappings;
+ public Set<GetChargeOffReasonsToExpenseMappings>
chargeOffReasonsToExpenseMappings;
@Schema(example = "false")
public Boolean isRatesEnabled;
@Schema(example = "true")
@@ -1577,6 +1589,7 @@ final class LoanProductsApiResourceSwagger {
public Long incomeFromChargeOffPenaltyAccountId;
public
List<GetLoanProductsProductIdResponse.GetLoanPaymentChannelToFundSourceMappings>
paymentChannelToFundSourceMappings;
public
List<GetLoanProductsProductIdResponse.GetLoanFeeToIncomeAccountMappings>
feeToIncomeAccountMappings;
+ public
List<GetLoanProductsProductIdResponse.GetChargeOffReasonsToExpenseMappings>
chargeOffReasonsToExpenseMappings;
public List<ChargeToGLAccountMapper> 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 c32997cb6..dd1273338 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<PaymentTypeToGLAccountMapper>
paymentChannelToFundSourceMappings;
private Collection<ChargeToGLAccountMapper> feeToIncomeAccountMappings;
private Collection<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
+ private List<ChargeToGLAccountMapper> chargeOffReasonsToExpenseMappings;
private final boolean enableAccrualActivityPosting;
// rates
@@ -853,6 +854,7 @@ public class LoanProductData implements Serializable {
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 class LoanProductData implements Serializable {
this.paymentChannelToFundSourceMappings =
productData.paymentChannelToFundSourceMappings;
this.feeToIncomeAccountMappings =
productData.feeToIncomeAccountMappings;
this.penaltyToIncomeAccountMappings =
productData.penaltyToIncomeAccountMappings;
+ this.chargeOffReasonsToExpenseMappings =
productData.chargeOffReasonsToExpenseMappings;
this.chargeOptions = chargeOptions;
this.penaltyOptions = penaltyOptions;
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java
b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java
index 53496003a..05d8a530c 100644
---
a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java
+++
b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java
@@ -130,6 +130,7 @@ public class
ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
// advanced accounting mappings
this.loanProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command,
element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command,
element, loanProductId, null);
+
this.loanProductToGLAccountMappingHelper.saveChargeOffReasonToExpenseAccountMappings(command,
element, loanProductId, null);
break;
case ACCRUAL_UPFRONT:
// Fall Through
@@ -208,6 +209,7 @@ public class
ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
// advanced accounting mappings
this.loanProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command,
element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command,
element, loanProductId, null);
+
this.loanProductToGLAccountMappingHelper.saveChargeOffReasonToExpenseAccountMappings(command,
element, loanProductId, null);
break;
}
}
@@ -379,6 +381,8 @@ public class
ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
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 7adcbdefd..b2ac25942 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,h [...]
+ + "Optional Fields: inArrearsTolerance, graceOnPrincipalPayment,
graceOnInterestPayment, graceOnInterestCharged, graceOnArrearsAgeing, charges,
paymentChannelToFundSourceMappings, feeToIncomeAccountMappings,
penaltyToIncomeAccountMappings, chargeOffReasonsToExpenseMappings,
includeInBorrowerCycle, useBorrowerCycle,principalVariationsForBorrowerCycle,
numberOfRepaymentVariationsForBorrowerCycle,
interestRateVariationsForBorrowerCycle, multiDisburseLoan,maxTrancheCount,
outstan [...]
+ "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 @@ public class LoanProductsApiResource {
Collection<PaymentTypeToGLAccountMapper>
paymentChannelToFundSourceMappings;
Collection<ChargeToGLAccountMapper> feeToGLAccountMappings;
Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings;
+ List<ChargeToGLAccountMapper> 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 3758e4e20..e0d45318e 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.math.BigDecimal;
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 lombok.RequiredArgsConstructor;
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 final class LoanProductDataValidator {
validatePaymentChannelFundSourceMappings(baseDataValidator,
element);
validateChargeToIncomeAccountMappings(baseDataValidator, element);
+ validateChargeOffToExpenseMappings(baseDataValidator, element);
}
@@ -1791,6 +1797,7 @@ public final class LoanProductDataValidator {
validatePaymentChannelFundSourceMappings(baseDataValidator, element);
validateChargeToIncomeAccountMappings(baseDataValidator, element);
+ validateChargeOffToExpenseMappings(baseDataValidator, element);
validateMinMaxConstraints(element, baseDataValidator, loanProduct);
@@ -1964,6 +1971,60 @@ public final class LoanProductDataValidator {
}
}
+ 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<Long, Set<Long>> chargeOffReasonToAccounts = new
HashMap<>();
+ List<JsonObject> 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<Long> 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 7b79a6754..2a0d2d0c3 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 @@
<include
file="parts/0150_transaction_summary_with_asset_owner_report_interest_waiver_interest_refund_added.xml"
relativeToChangelogFile="true" />
<include file="parts/0151_interest_refund_business_events.xml"
relativeToChangelogFile="true" />
<include file="parts/0152_update_password_validation_policy.xml"
relativeToChangelogFile="true" />
+ <include
file="parts/0153_add_charge_off_reason_id_to_acc_product_mapping.xml"
relativeToChangelogFile="true" />
</databaseChangeLog>
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 000000000..5002a38bb
--- /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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
+
+ <changeSet id="1" author="fineract">
+ <addColumn tableName="acc_product_mapping">
+ <column name="charge_off_reason_id" type="INT"
defaultValueNumeric="NULL">
+ <constraints nullable="true"/>
+ </column>
+ </addColumn>
+ <addForeignKeyConstraint
+ baseTableName="acc_product_mapping"
+ baseColumnNames="charge_off_reason_id"
+ referencedTableName="m_code_value"
+ referencedColumnNames="id"
+ constraintName="fk_acc_product_mapping_charge_off_reason"/>
+ </changeSet>
+
+</databaseChangeLog>
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 000000000..95d8a735c
--- /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<GetChargeOffReasonsToExpenseMappings>
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<Integer> principalVariationsForBorrowerCycle = new ArrayList<>();
+ List<Integer> numberOfRepaymentVariationsForBorrowerCycle = new
ArrayList<>();
+ List<Integer> interestRateVariationsForBorrowerCycle = new
ArrayList<>();
+ List<ChargeData> charges = new ArrayList<>();
+ List<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings = new
ArrayList<>();
+ List<GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings =
new ArrayList<>();
+
+ List<GetChargeOffReasonsToExpenseMappings>
chargeOffReasonsToExpenseMappings = new ArrayList<>();
+ GetChargeOffReasonsToExpenseMappings
getChargeOffReasonsToExpenseMappings = new
GetChargeOffReasonsToExpenseMappings();
+
getChargeOffReasonsToExpenseMappings.setChargeOffReasonCodeValueId(chargeOffReasonId);
+
getChargeOffReasonsToExpenseMappings.setExpenseGLAccountId(glAccountId);
+
chargeOffReasonsToExpenseMappings.add(getChargeOffReasonsToExpenseMappings);
+
+ List<GetLoanPaymentChannelToFundSourceMappings>
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<String, Object> 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<String, Object> 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 e9439c259..75624a8c5 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<Map<String, Long>> feeToIncomeAccountMappings = null;
private List<Map<String, Long>> penaltyToIncomeAccountMappings = null;
+ private List<Map<String, Long>> chargeOffReasonsToExpenseMappings = null;
private Account feeAndPenaltyAssetAccount;
private Boolean multiDisburseLoan = false;
@@ -303,6 +304,10 @@ public class LoanProductTestBuilder {
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 class LoanProductTestBuilder {
return this;
}
+ public LoanProductTestBuilder withChargeOffReasonsToExpenseMappings(final
Long reasonId, final Long accountId) {
+ if (this.chargeOffReasonsToExpenseMappings == null) {
+ this.chargeOffReasonsToExpenseMappings = new ArrayList<>();
+ }
+ Map<String, Long> 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 6eed20b57..2f0cce363 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 final class CodeHelper {
return code;
}
+ public static HashMap<String, Object>
getOrCreateCodeValueByCodeIdAndCodeName(final RequestSpecification requestSpec,
+ final ResponseSpecification responseSpec, final Integer codeId,
final String codeName, final Integer position) {
+
+ ArrayList<HashMap<String, Object>> allCodeValues =
CodeHelper.getAllCodeValuesByCodeId(requestSpec, responseSpec, codeId);
+ HashMap<String, Object> 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<String, Object>
filterCodesByName(ArrayList<HashMap<String, Object>> allCodeValues, String
codeName) {
+ final HashMap<String, Object> codes = new HashMap<>();
+
+ for (HashMap<String, Object> 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<String, Object> retrieveOrCreateCodeValue(Integer
codeId, final RequestSpecification requestSpec,
final ResponseSpecification responseSpec) {
Integer codeValueId = null;
@@ -119,6 +148,13 @@ public final class CodeHelper {
}
+ public static ArrayList<HashMap<String, Object>>
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()