FINERACT-2358: Allow to configure advanced accounting rules based on write-off reason
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 12edabf..34442ba 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
@@ -69,6 +69,10 @@
private CodeValue chargeOffReason;
@ManyToOne
+ @JoinColumn(name = "write_off_reason_id", nullable = true)
+ private CodeValue writeOffReason;
+
+ @ManyToOne
@JoinColumn(name = "capitalized_income_classification_id", nullable = true)
private CodeValue capitalizedIncomeClassification;
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 917fbdf..0ad7c3d 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 @@
@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 and mapping.chargeOffReason is NULL and mapping.capitalizedIncomeClassification is NULL and mapping.buydownFeeClassification 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.chargeOffReason is NULL and mapping.writeOffReason is NULL and mapping.capitalizedIncomeClassification is NULL and mapping.buydownFeeClassification is NULL")
ProductToGLAccountMapping findCoreProductToFinAccountMapping(@Param("productId") Long productId, @Param("productType") int productType,
@Param("financialAccountType") int financialAccountType);
@@ -66,11 +66,18 @@
List<ProductToGLAccountMapping> findAllChargeOffReasonsMappings(@Param("productId") Long productId,
@Param("productType") int productType);
+ List<ProductToGLAccountMapping> findAllProductToGLAccountMappingsByProductIdAndProductTypeAndFinancialAccountType(Long productId,
+ int productType, int financialAccountType);
+
+ @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.writeOffReason is not NULL")
+ List<ProductToGLAccountMapping> findAllWriteOffReasonsMappings(@Param("productId") Long productId,
+ @Param("productType") int productType);
+
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.chargeOffReason.id =:chargeOffReasonId AND mapping.productId =:productId AND mapping.productType =:productType")
ProductToGLAccountMapping findChargeOffReasonMapping(@Param("productId") Long productId, @Param("productType") Integer productType,
@Param("chargeOffReasonId") Long chargeOffReasonId);
- @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId AND mapping.productType =:productType AND mapping.charge IS NULL AND mapping.paymentType IS NULL AND mapping.chargeOffReason IS NULL AND mapping.capitalizedIncomeClassification is NULL AND mapping.buydownFeeClassification is NULL")
+ @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId AND mapping.productType =:productType AND mapping.charge IS NULL AND mapping.paymentType IS NULL AND mapping.chargeOffReason IS NULL AND mapping.writeOffReason IS NULL AND mapping.capitalizedIncomeClassification is NULL AND mapping.buydownFeeClassification is NULL")
List<ProductToGLAccountMapping> findAllRegularMappings(@Param("productId") Long productId, @Param("productType") Integer productType);
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.paymentType is not NULL")
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 dfbd94c..059ac48 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
@@ -29,6 +29,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams;
@@ -206,23 +207,25 @@
}
}
- public void saveChargeOffReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
- final Map<String, Object> changes, final PortfolioProductType portfolioProductType) {
+ public void saveReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
+ final Map<String, Object> changes, final PortfolioProductType portfolioProductType,
+ final LoanProductAccountingParams arrayNameParam, final LoanProductAccountingParams reasonCodeValueIdParam,
+ final CashAccountsForLoan cashAccountsForLoan) {
- final String arrayName = LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue();
- final JsonArray chargeOffReasonToExpenseAccountMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(arrayName, element);
+ final String arrayName = arrayNameParam.getValue();
+ final JsonArray reasonToExpenseAccountMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(arrayName, element);
- if (chargeOffReasonToExpenseAccountMappingArray != null) {
+ if (reasonToExpenseAccountMappingArray != 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();
+ for (int i = 0; i < reasonToExpenseAccountMappingArray.size(); i++) {
+ final JsonObject jsonObject = reasonToExpenseAccountMappingArray.get(i).getAsJsonObject();
+ final Long reasonId = jsonObject.get(reasonCodeValueIdParam.getValue()).getAsLong();
final Long expenseAccountId = jsonObject.get(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()).getAsLong();
- saveChargeOffReasonToExpenseMapping(productId, reasonId, expenseAccountId, portfolioProductType);
+ saveReasonToExpenseMapping(productId, reasonId, expenseAccountId, portfolioProductType, cashAccountsForLoan);
}
}
}
@@ -413,60 +416,74 @@
}
}
- public void updateChargeOffReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
- final Map<String, Object> changes, final PortfolioProductType portfolioProductType) {
+ private Long getReasonIdByCashAccountForLoan(final ProductToGLAccountMapping productToGLAccountMapping,
+ final CashAccountsForLoan cashAccountsForLoan) {
+ return switch (cashAccountsForLoan) {
+ case LOSSES_WRITTEN_OFF -> productToGLAccountMapping != null && productToGLAccountMapping.getWriteOffReason() != null
+ ? productToGLAccountMapping.getWriteOffReason().getId()
+ : null;
+ case CHARGE_OFF_EXPENSE -> productToGLAccountMapping != null && productToGLAccountMapping.getChargeOffReason() != null
+ ? productToGLAccountMapping.getChargeOffReason().getId()
+ : null;
+ default -> throw new IllegalStateException("Unexpected value: " + cashAccountsForLoan);
+ };
+ }
- final List<ProductToGLAccountMapping> existingChargeOffReasonToGLAccountMappings = this.accountMappingRepository
- .findAllChargeOffReasonsMappings(productId, portfolioProductType.getValue());
- final JsonArray chargeOffReasonToGLAccountMappingArray = this.fromApiJsonHelper
- .extractJsonArrayNamed(LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue(), element);
+ public void updateReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
+ final Map<String, Object> changes, final PortfolioProductType portfolioProductType,
+ final List<ProductToGLAccountMapping> existingReasonToGLAccountMappings,
+ final LoanProductAccountingParams reasonToExpenseAccountMappingsParam, final LoanProductAccountingParams reasonCodeValueIdParam,
+ final CashAccountsForLoan cashAccountsForLoan) {
- final Map<Long, Long> inputChargeOffReasonToGLAccountMap = new HashMap<>();
+ final JsonArray reasonToGLAccountMappingArray = this.fromApiJsonHelper
+ .extractJsonArrayNamed(reasonToExpenseAccountMappingsParam.getValue(), element);
- final Set<Long> existingChargeOffReasons = new HashSet<>();
- if (chargeOffReasonToGLAccountMappingArray != null) {
+ final Map<Long, Long> inputReasonToGLAccountMap = new HashMap<>();
+
+ final Set<Long> existingReasons = new HashSet<>();
+ if (reasonToGLAccountMappingArray != null) {
if (changes != null) {
- changes.put(LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue(),
- command.jsonFragment(LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue()));
+ changes.put(reasonToExpenseAccountMappingsParam.getValue(),
+ command.jsonFragment(reasonToExpenseAccountMappingsParam.getValue()));
}
- for (int i = 0; i < chargeOffReasonToGLAccountMappingArray.size(); i++) {
- final JsonObject jsonObject = chargeOffReasonToGLAccountMappingArray.get(i).getAsJsonObject();
+ for (int i = 0; i < reasonToGLAccountMappingArray.size(); i++) {
+ final JsonObject jsonObject = reasonToGLAccountMappingArray.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);
+ final Long reasonCodeValueId = jsonObject.get(reasonCodeValueIdParam.getValue()).getAsLong();
+ inputReasonToGLAccountMap.put(reasonCodeValueId, expenseGlAccountId);
}
// If input map is empty, delete all existing mappings
- if (inputChargeOffReasonToGLAccountMap.isEmpty()) {
- this.accountMappingRepository.deleteAll(existingChargeOffReasonToGLAccountMappings);
+ if (inputReasonToGLAccountMap.isEmpty()) {
+ this.accountMappingRepository.deleteAll(existingReasonToGLAccountMappings);
+
} else {
- for (final ProductToGLAccountMapping existingChargeOffReasonToGLAccountMapping : existingChargeOffReasonToGLAccountMappings) {
- final Long currentChargeOffReasonId = existingChargeOffReasonToGLAccountMapping.getChargeOffReason().getId();
- if (currentChargeOffReasonId != null) {
- existingChargeOffReasons.add(currentChargeOffReasonId);
+ for (final ProductToGLAccountMapping existingReasonToGLAccountMapping : existingReasonToGLAccountMappings) {
+ final Long currentReasonId = getReasonIdByCashAccountForLoan(existingReasonToGLAccountMapping, cashAccountsForLoan);
+ if (currentReasonId != null) {
+ existingReasons.add(currentReasonId);
// update existing mappings (if required)
- if (inputChargeOffReasonToGLAccountMap.containsKey(currentChargeOffReasonId)) {
- final Long newGLAccountId = inputChargeOffReasonToGLAccountMap.get(currentChargeOffReasonId);
- if (!newGLAccountId.equals(existingChargeOffReasonToGLAccountMapping.getGlAccount().getId())) {
+ if (inputReasonToGLAccountMap.containsKey(currentReasonId)) {
+ final Long newGLAccountId = inputReasonToGLAccountMap.get(currentReasonId);
+ if (!newGLAccountId.equals(existingReasonToGLAccountMapping.getGlAccount().getId())) {
final Optional<GLAccount> glAccount = accountRepository.findById(newGLAccountId);
if (glAccount.isPresent()) {
- existingChargeOffReasonToGLAccountMapping.setGlAccount(glAccount.get());
- this.accountMappingRepository.saveAndFlush(existingChargeOffReasonToGLAccountMapping);
+ existingReasonToGLAccountMapping.setGlAccount(glAccount.get());
+ this.accountMappingRepository.saveAndFlush(existingReasonToGLAccountMapping);
}
}
} // deleted payment type
else {
- this.accountMappingRepository.delete(existingChargeOffReasonToGLAccountMapping);
+ this.accountMappingRepository.delete(existingReasonToGLAccountMapping);
}
}
}
// 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);
+ for (Map.Entry<Long, Long> entry : inputReasonToGLAccountMap.entrySet().stream()
+ .filter(e -> !existingReasons.contains(e.getKey())).toList()) {
+ saveReasonToExpenseMapping(productId, entry.getKey(), entry.getValue(), portfolioProductType, cashAccountsForLoan);
}
}
}
@@ -587,21 +604,37 @@
this.accountMappingRepository.saveAndFlush(accountMapping);
}
- private void saveChargeOffReasonToExpenseMapping(final Long productId, final Long reasonId, final Long expenseAccountId,
- final PortfolioProductType portfolioProductType) {
+ private Predicate<? super ProductToGLAccountMapping> matching(final CashAccountsForLoan typeDef, final Long reasonId) {
+ return switch (typeDef) {
+ case CHARGE_OFF_EXPENSE -> (mapping) -> (mapping.getChargeOffReason() != null && mapping.getChargeOffReason().getId() != null
+ && mapping.getChargeOffReason().getId().equals(reasonId));
+ case LOSSES_WRITTEN_OFF -> (mapping) -> (mapping.getWriteOffReason() != null && mapping.getWriteOffReason().getId() != null
+ && mapping.getWriteOffReason().getId().equals(reasonId));
+ default -> throw new IllegalStateException("Unexpected value: " + typeDef);
+ };
+ }
+
+ private void saveReasonToExpenseMapping(final Long productId, final Long reasonId, final Long expenseAccountId,
+ final PortfolioProductType portfolioProductType, final CashAccountsForLoan cashAccountsForLoan) {
final Optional<GLAccount> glAccount = accountRepository.findById(expenseAccountId);
-
- final boolean reasonMappingExists = this.accountMappingRepository
- .findAllChargeOffReasonsMappings(productId, portfolioProductType.getValue()).stream()
- .anyMatch(mapping -> mapping.getChargeOffReason().getId().equals(reasonId));
-
final Optional<CodeValue> codeValueOptional = codeValueRepository.findById(reasonId);
- if (glAccount.isPresent() && !reasonMappingExists && codeValueOptional.isPresent()) {
+ final boolean reasonMappingExists = this.accountMappingRepository
+ .findAllProductToGLAccountMappingsByProductIdAndProductTypeAndFinancialAccountType(productId,
+ portfolioProductType.getValue(), cashAccountsForLoan.getValue())
+ .stream().anyMatch(matching(cashAccountsForLoan, reasonId));
+
+ if (!reasonMappingExists && glAccount.isPresent() && codeValueOptional.isPresent()) {
final ProductToGLAccountMapping accountMapping = new ProductToGLAccountMapping().setGlAccount(glAccount.get())
.setProductId(productId).setProductType(portfolioProductType.getValue())
- .setFinancialAccountType(CashAccountsForLoan.CHARGE_OFF_EXPENSE.getValue()).setChargeOffReason(codeValueOptional.get());
+ .setFinancialAccountType(cashAccountsForLoan.getValue());
+
+ switch (cashAccountsForLoan) {
+ case CHARGE_OFF_EXPENSE -> accountMapping.setChargeOffReason(codeValueOptional.get());
+ case LOSSES_WRITTEN_OFF -> accountMapping.setWriteOffReason(codeValueOptional.get());
+ default -> throw new IllegalStateException("Unexpected value: " + cashAccountsForLoan);
+ }
this.accountMappingRepository.saveAndFlush(accountMapping);
}
@@ -703,22 +736,25 @@
}
}
- public void validateChargeOffMappingsInDatabase(final List<JsonObject> mappings) {
- final List<ApiParameterError> validationErrors = new ArrayList<>();
+ public void validateWriteOffMappingsInDatabase(final List<ApiParameterError> validationErrors, final List<JsonObject> mappings) {
+ for (JsonObject jsonObject : mappings) {
+ final Long writeOffReasonCodeValueId = this.fromApiJsonHelper
+ .extractLongNamed(LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID.getValue(), jsonObject);
+ // Validation: writeOffReasonCodeValueId must exist in the database
+ CodeValue codeValue = this.codeValueRepository.findByCodeNameAndId("WriteOffReasons", writeOffReasonCodeValueId);
+ if (codeValue == null) {
+ validationErrors.add(ApiParameterError.parameterError("validation.msg.writeoffreason.invalid",
+ "Write-off reason with ID " + writeOffReasonCodeValueId + " does not exist",
+ LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue()));
+ }
+ }
+ }
+
+ public void validateGLAccountInDatabase(final List<ApiParameterError> validationErrors, final List<JsonObject> mappings) {
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_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue()));
- }
// Validation: expenseGLAccountId must exist as a valid Expense GL account
final Optional<GLAccount> glAccount = accountRepository.findById(expenseGlAccountId);
@@ -730,6 +766,22 @@
}
}
+ }
+
+ public void validateChargeOffMappingsInDatabase(final List<ApiParameterError> validationErrors, final List<JsonObject> mappings) {
+
+ for (JsonObject jsonObject : mappings) {
+ 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_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue()));
+ }
+ }
// Throw all collected validation errors, if any
if (!validationErrors.isEmpty()) {
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 22ccc37..d7af51f 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
@@ -25,6 +25,7 @@
import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.data.ClassificationToGLAccountData;
import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper;
+import org.apache.fineract.accounting.producttoaccountmapping.data.WriteOffReasonsToExpenseAccountMapper;
public interface ProductToGLAccountMappingReadPlatformService {
@@ -52,6 +53,8 @@
List<ChargeOffReasonToGLAccountMapper> fetchChargeOffReasonMappingsForLoanProduct(Long loanProductId);
+ List<WriteOffReasonsToExpenseAccountMapper> fetchWriteOffReasonMappingsForLoanProduct(Long loanProductId);
+
List<ClassificationToGLAccountData> fetchClassificationMappingsForLoanProduct(Long loanProductId,
LoanProductAccountingParams classificationParameter);
}
diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java
index 1fcb7bf..078b2b8 100644
--- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java
+++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java
@@ -40,6 +40,7 @@
import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.data.ClassificationToGLAccountData;
import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper;
+import org.apache.fineract.accounting.producttoaccountmapping.data.WriteOffReasonsToExpenseAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
@@ -293,6 +294,22 @@
return chargeOffReasonToGLAccountMappers;
}
+ private List<WriteOffReasonsToExpenseAccountMapper> fetchWriteOffReasonMappings(final PortfolioProductType portfolioProductType,
+ final Long loanProductId) {
+ final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllWriteOffReasonsMappings(loanProductId,
+ portfolioProductType.getValue());
+ List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseAccountMappers = mappings.isEmpty() ? null : new ArrayList<>();
+ for (final ProductToGLAccountMapping mapping : mappings) {
+ final String glCode = String.valueOf(mapping.getGlAccount().getId());
+ final String writeOffReasonId = String.valueOf(mapping.getWriteOffReason().getId());
+
+ final WriteOffReasonsToExpenseAccountMapper writeOffReasonToGLAccountMapper = new WriteOffReasonsToExpenseAccountMapper()
+ .setWriteOffReasonCodeValueId(writeOffReasonId).setExpenseAccountId(glCode);
+ writeOffReasonsToExpenseAccountMappers.add(writeOffReasonToGLAccountMapper);
+ }
+ return writeOffReasonsToExpenseAccountMappers;
+ }
+
private List<ClassificationToGLAccountData> fetchClassificationMappings(final PortfolioProductType portfolioProductType,
final Long loanProductId, LoanProductAccountingParams classificationParameter) {
final List<ProductToGLAccountMapping> mappings = classificationParameter
@@ -368,6 +385,11 @@
}
@Override
+ public List<WriteOffReasonsToExpenseAccountMapper> fetchWriteOffReasonMappingsForLoanProduct(Long loanProductId) {
+ return fetchWriteOffReasonMappings(PortfolioProductType.LOAN, loanProductId);
+ }
+
+ @Override
public List<ClassificationToGLAccountData> fetchClassificationMappingsForLoanProduct(Long loanProductId,
LoanProductAccountingParams classificationParameter) {
return fetchClassificationMappings(PortfolioProductType.LOAN, loanProductId, classificationParameter);
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 7435a46..b05b2d6 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
@@ -181,8 +181,10 @@
INCOME_FROM_GOODWILL_CREDIT_FEES("incomeFromGoodwillCreditFeesAccountId"), //
INCOME_FROM_GOODWILL_CREDIT_PENALTY("incomeFromGoodwillCreditPenaltyAccountId"), //
CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS("chargeOffReasonToExpenseAccountMappings"), //
+ WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS("writeOffReasonsToExpenseMappings"), //
EXPENSE_GL_ACCOUNT_ID("expenseAccountId"), //
CHARGE_OFF_REASON_CODE_VALUE_ID("chargeOffReasonCodeValueId"), //
+ WRITE_OFF_REASON_CODE_VALUE_ID("writeOffReasonCodeValueId"), //
DEFERRED_INCOME_LIABILITY("deferredIncomeLiabilityAccountId"), //
INCOME_FROM_CAPITALIZATION("incomeFromCapitalizationAccountId"), //
BUY_DOWN_EXPENSE("buyDownExpenseAccountId"), //
diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/data/WriteOffReasonsToExpenseAccountMapper.java b/fineract-core/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/data/WriteOffReasonsToExpenseAccountMapper.java
new file mode 100644
index 0000000..df49b8e
--- /dev/null
+++ b/fineract-core/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/data/WriteOffReasonsToExpenseAccountMapper.java
@@ -0,0 +1,36 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.accounting.producttoaccountmapping.data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.experimental.Accessors;
+
+@Data
+@NoArgsConstructor
+@Accessors(chain = true)
+public class WriteOffReasonsToExpenseAccountMapper implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+ private String writeOffReasonCodeValueId;
+ private String expenseAccountId;
+}
diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java
index 13ab2a2..253b4a8 100644
--- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java
+++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java
@@ -531,13 +531,20 @@
}
if (this.value != null) {
- final long number = Long.parseLong(this.value.toString());
- if (number < 1) {
- String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + ".not.greater.than.zero";
- String defaultEnglishMessage = "The parameter `" + this.parameter + "` must be greater than 0.";
- final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultEnglishMessage, this.parameter,
- number, 0);
- this.dataValidationErrors.add(error);
+ try {
+ final long number = Long.parseLong(this.value.toString());
+ if (number < 1) {
+ String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + ".not.greater.than.zero";
+ String defaultEnglishMessage = "The parameter `" + this.parameter + "` must be greater than 0.";
+ final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultEnglishMessage,
+ this.parameter, number, 0);
+ this.dataValidationErrors.add(error);
+ }
+ } catch (NumberFormatException e) {
+ String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + ".not.a.number";
+ String defaultEnglishMessage = "The parameter `" + this.parameter + "` must be a number.";
+ this.dataValidationErrors.add(ApiParameterError.parameterError(validationErrorCode, defaultEnglishMessage, this.parameter));
+ throwValidationErrors();
}
}
return this;
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 80b6427..ecffdb7 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
@@ -30,6 +30,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.ProductToGLAccountMapping;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingInvalidException;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingHelper;
@@ -141,12 +142,39 @@
public void saveChargeOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes) {
- saveChargeOffReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN);
+ saveReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN,
+ LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS,
+ LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID, CashAccountsForLoan.CHARGE_OFF_EXPENSE);
+ }
+
+ public void saveWriteOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
+ final Map<String, Object> changes) {
+ saveReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN,
+ LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS,
+ LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID, CashAccountsForLoan.LOSSES_WRITTEN_OFF);
+ }
+
+ public void updateWriteOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
+ final Map<String, Object> changes) {
+ final List<ProductToGLAccountMapping> existingWriteOffReasonToGLAccountMappings = this.accountMappingRepository
+ .findAllWriteOffReasonsMappings(productId, PortfolioProductType.LOAN.getValue());
+ LoanProductAccountingParams reasonToExpenseAccountMappingsParam = LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS;
+ LoanProductAccountingParams reasonCodeValueIdParam = LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID;
+ CashAccountsForLoan cashAccountsForLoan = CashAccountsForLoan.LOSSES_WRITTEN_OFF;
+ updateReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN,
+ existingWriteOffReasonToGLAccountMappings, reasonToExpenseAccountMappingsParam, reasonCodeValueIdParam,
+ cashAccountsForLoan);
}
public void updateChargeOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes) {
- updateChargeOffReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN);
+ final List<ProductToGLAccountMapping> chargeOffReasonsMappings = this.accountMappingRepository
+ .findAllChargeOffReasonsMappings(productId, PortfolioProductType.LOAN.getValue());
+ LoanProductAccountingParams reasonToExpenseAccountMappingsParam = LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS;
+ LoanProductAccountingParams reasonCodeValueIdParam = LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID;
+ CashAccountsForLoan cashAccountsForLoan = CashAccountsForLoan.CHARGE_OFF_EXPENSE;
+ updateReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN, chargeOffReasonsMappings,
+ reasonToExpenseAccountMappingsParam, reasonCodeValueIdParam, cashAccountsForLoan);
}
public void saveCapitalizedIncomeClassificationToIncomeAccountMappings(final JsonCommand command, final JsonElement element,
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 ccb47b2..79c007b 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
@@ -301,6 +301,7 @@
public List<GetLoanProductsProductIdResponse.GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings;
public List<LoanProductChargeToGLAccountMapper> feeToIncomeAccountMappings;
public List<PostChargeOffReasonToExpenseAccountMappings> chargeOffReasonToExpenseAccountMappings;
+ public List<PostWriteOffReasonToExpenseAccountMappings> writeOffReasonsToExpenseMappings;
public List<PostLoanProductsRequest.PostClassificationToIncomeAccountMappings> buydownfeeClassificationToIncomeAccountMappings;
public List<PostLoanProductsRequest.PostClassificationToIncomeAccountMappings> capitalizedIncomeClassificationToIncomeAccountMappings;
public List<LoanProductChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
@@ -372,7 +373,7 @@
@Schema(example = "REGULAR")
public String chargeOffBehaviour;
- static final class PostChargeOffReasonToExpenseAccountMappings {
+ public static final class PostChargeOffReasonToExpenseAccountMappings {
private PostChargeOffReasonToExpenseAccountMappings() {}
@@ -382,6 +383,17 @@
public Long expenseAccountId;
}
+ @Schema(description = "PostWriteOffReasonToExpenseAccountMappings")
+ public static final class PostWriteOffReasonToExpenseAccountMappings {
+
+ private PostWriteOffReasonToExpenseAccountMappings() {}
+
+ @Schema(example = "1")
+ public String writeOffReasonCodeValueId;
+ @Schema(example = "1")
+ public String expenseAccountId;
+ }
+
static final class PostClassificationToIncomeAccountMappings {
private PostClassificationToIncomeAccountMappings() {}
@@ -1154,6 +1166,7 @@
public List<StringEnumOptionData> supportedInterestRefundTypes;
public List<StringEnumOptionData> supportedInterestRefundTypesOptions;
public List<GetLoanProductsChargeOffReasonOptions> chargeOffReasonOptions;
+ public List<GetLoanProductsWriteOffReasonOptions> writeOffReasonOptions;
public StringEnumOptionData chargeOffBehaviour;
public List<StringEnumOptionData> chargeOffBehaviourOptions;
@Schema(example = "false")
@@ -1380,6 +1393,17 @@
public Long incomeAccountId;
}
+ @Schema(description = "GetWriteOffReasonToExpenseAccountMappings")
+ public static final class GetWriteOffReasonToExpenseAccountMappings {
+
+ private GetWriteOffReasonToExpenseAccountMappings() {}
+
+ @Schema(example = "1")
+ public String writeOffReasonCodeValueId;
+ @Schema(example = "1")
+ public String expenseAccountId;
+ }
+
@Schema(example = "11")
public Long id;
@Schema(example = "advanced accounting")
@@ -1468,6 +1492,7 @@
public Set<GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings;
public Set<GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings;
public List<GetChargeOffReasonToExpenseAccountMappings> chargeOffReasonToExpenseAccountMappings;
+ public List<PostLoanProductsRequest.PostWriteOffReasonToExpenseAccountMappings> writeOffReasonsToExpenseMappings;
@Schema(example = "false")
public Boolean isRatesEnabled;
@Schema(example = "true")
@@ -1511,6 +1536,7 @@
public Boolean enableAccrualActivityPosting;
public List<StringEnumOptionData> supportedInterestRefundTypes;
public List<GetLoanProductsChargeOffReasonOptions> chargeOffReasonOptions;
+ public List<GetLoanProductsWriteOffReasonOptions> writeOffReasonOptions;
public StringEnumOptionData chargeOffBehaviour;
@Schema(example = "false")
public Boolean interestRecognitionOnDisbursementDate;
@@ -1774,6 +1800,7 @@
public List<GetLoanProductsProductIdResponse.GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings;
public List<LoanProductChargeToGLAccountMapper> feeToIncomeAccountMappings;
public List<PostLoanProductsRequest.PostChargeOffReasonToExpenseAccountMappings> chargeOffReasonToExpenseAccountMappings;
+ public List<PostLoanProductsRequest.PostWriteOffReasonToExpenseAccountMappings> writeOffReasonsToExpenseMappings;
public List<PostLoanProductsRequest.PostClassificationToIncomeAccountMappings> buydownfeeClassificationToIncomeAccountMappings;
public List<PostLoanProductsRequest.PostClassificationToIncomeAccountMappings> capitalizedIncomeClassificationToIncomeAccountMappings;
public List<LoanProductChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
@@ -1904,4 +1931,24 @@
@Schema(example = "false")
public Boolean mandatory;
}
+
+ @Schema(description = "GetLoanProductsWriteOffReasonOptions")
+ public static final class GetLoanProductsWriteOffReasonOptions {
+
+ private GetLoanProductsWriteOffReasonOptions() {}
+
+ @Schema(example = "2")
+ public Long id;
+ @Schema(example = "debit_card")
+ public String name;
+ @Schema(example = "2")
+ public Integer position;
+ @Schema(example = "Write-Off reason description")
+ public String description;
+ @Schema(example = "true")
+ public Boolean active;
+ @Schema(example = "false")
+ public Boolean mandatory;
+ }
+
}
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 87660df..23066f3 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
@@ -34,6 +34,7 @@
import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.data.ClassificationToGLAccountData;
import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper;
+import org.apache.fineract.accounting.producttoaccountmapping.data.WriteOffReasonsToExpenseAccountMapper;
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
import org.apache.fineract.infrastructure.core.api.ApiFacingEnum;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
@@ -165,7 +166,8 @@
private Collection<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
private List<ChargeOffReasonToGLAccountMapper> chargeOffReasonToExpenseAccountMappings;
private final boolean enableAccrualActivityPosting;
-
+ private List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseMappings;
+ private final List<CodeValueData> writeOffReasonOptions;
// rates
private final boolean isRatesEnabled;
private final Collection<RateData> rates;
@@ -379,6 +381,8 @@
final StringEnumOptionData buyDownFeeStrategy = null;
final StringEnumOptionData buyDownFeeIncomeType = null;
final boolean merchantBuyDownFee = false;
+ final List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseMappings = null;
+ final List<CodeValueData> writeOffReasonOptions = null;
return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance,
numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod,
@@ -402,7 +406,8 @@
loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour,
interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization,
capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee,
- buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee);
+ buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings,
+ writeOffReasonOptions);
}
@@ -516,6 +521,8 @@
final StringEnumOptionData buyDownFeeStrategy = null;
final StringEnumOptionData buyDownFeeIncomeType = null;
final boolean merchantBuyDownFee = false;
+ final List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseMappings = null;
+ final List<CodeValueData> writeOffReasonOptions = null;
return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance,
numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod,
@@ -539,7 +546,8 @@
loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour,
interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization,
capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee,
- buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee);
+ buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings,
+ writeOffReasonOptions);
}
@@ -660,6 +668,8 @@
final StringEnumOptionData buyDownFeeStrategy = null;
final StringEnumOptionData buyDownFeeIncomeType = null;
final boolean merchantBuyDownFee = false;
+ final List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseMappings = null;
+ final List<CodeValueData> writeOffReasonOptions = null;
return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance,
numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod,
@@ -683,7 +693,8 @@
loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour,
interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization,
capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee,
- buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee);
+ buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings,
+ writeOffReasonOptions);
}
@@ -798,6 +809,8 @@
final StringEnumOptionData buyDownFeeStrategy = null;
final StringEnumOptionData buyDownFeeIncomeType = null;
final boolean merchantBuyDownFee = false;
+ final List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseMappings = null;
+ final List<CodeValueData> writeOffReasonOptions = null;
return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance,
numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod,
@@ -821,7 +834,8 @@
loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour,
interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization,
capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee,
- buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee);
+ buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings,
+ writeOffReasonOptions);
}
public static LoanProductData withAccountingDetails(final LoanProductData productData, final Map<String, Object> accountingMappings,
@@ -829,6 +843,7 @@
final Collection<ChargeToGLAccountMapper> feeToGLAccountMappings,
final Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings,
final List<ChargeOffReasonToGLAccountMapper> chargeOffReasonToGLAccountMappings,
+ final List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonToGLAccountMappings,
final List<ClassificationToGLAccountData> capitalizedIncomeClassificationToIncomeAccountMappings,
final List<ClassificationToGLAccountData> buydownFeeClassificationToIncomeAccountMappings) {
productData.accountingMappings = accountingMappings;
@@ -836,6 +851,7 @@
productData.feeToIncomeAccountMappings = feeToGLAccountMappings;
productData.penaltyToIncomeAccountMappings = penaltyToGLAccountMappings;
productData.chargeOffReasonToExpenseAccountMappings = chargeOffReasonToGLAccountMappings;
+ productData.writeOffReasonsToExpenseMappings = writeOffReasonToGLAccountMappings;
productData.capitalizedIncomeClassificationToIncomeAccountMappings = capitalizedIncomeClassificationToIncomeAccountMappings;
productData.buydownFeeClassificationToIncomeAccountMappings = buydownFeeClassificationToIncomeAccountMappings;
return productData;
@@ -884,7 +900,9 @@
final StringEnumOptionData capitalizedIncomeCalculationType, final StringEnumOptionData capitalizedIncomeStrategy,
final StringEnumOptionData capitalizedIncomeType, final boolean enableBuyDownFee,
final StringEnumOptionData buyDownFeeCalculationType, final StringEnumOptionData buyDownFeeStrategy,
- final StringEnumOptionData buyDownFeeIncomeType, final boolean merchantBuyDownFee) {
+ final StringEnumOptionData buyDownFeeIncomeType, final boolean merchantBuyDownFee,
+ final List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseMappings,
+ final List<CodeValueData> writeOffReasonOptions) {
this.id = id;
this.name = name;
this.shortName = shortName;
@@ -971,6 +989,7 @@
this.feeToIncomeAccountMappings = null;
this.penaltyToIncomeAccountMappings = null;
this.chargeOffReasonToExpenseAccountMappings = null;
+ this.writeOffReasonsToExpenseMappings = null;
this.valueConditionTypeOptions = null;
this.principalVariationsForBorrowerCycle = principalVariations;
this.interestRateVariationsForBorrowerCycle = interestRateVariations;
@@ -1046,6 +1065,8 @@
this.buyDownFeeCalculationTypeOptions = ApiFacingEnum.getValuesAsStringEnumOptionDataList(LoanBuyDownFeeCalculationType.class);
this.buyDownFeeStrategyOptions = ApiFacingEnum.getValuesAsStringEnumOptionDataList(LoanBuyDownFeeStrategy.class);
this.buyDownFeeIncomeTypeOptions = ApiFacingEnum.getValuesAsStringEnumOptionDataList(LoanBuyDownFeeIncomeType.class);
+ this.writeOffReasonsToExpenseMappings = writeOffReasonsToExpenseMappings;
+ this.writeOffReasonOptions = writeOffReasonOptions;
this.capitalizedIncomeClassificationOptions = null;
this.buydownFeeClassificationOptions = null;
this.capitalizedIncomeClassificationToIncomeAccountMappings = null;
@@ -1079,8 +1100,8 @@
final List<StringEnumOptionData> capitalizedIncomeStrategyOptions,
final List<StringEnumOptionData> capitalizedIncomeTypeOptions,
final List<StringEnumOptionData> buyDownFeeCalculationTypeOptions, final List<StringEnumOptionData> buyDownFeeStrategyOptions,
- final List<StringEnumOptionData> buyDownFeeIncomeTypeOptions, final List<CodeValueData> capitalizedIncomeClassificationOptions,
- final List<CodeValueData> buydownFeeClassificationOptions) {
+ final List<StringEnumOptionData> buyDownFeeIncomeTypeOptions, final List<CodeValueData> writeOffReasonOptions,
+ final List<CodeValueData> capitalizedIncomeClassificationOptions, final List<CodeValueData> buydownFeeClassificationOptions) {
this.id = productData.id;
this.name = productData.name;
@@ -1134,6 +1155,8 @@
this.feeToIncomeAccountMappings = productData.feeToIncomeAccountMappings;
this.penaltyToIncomeAccountMappings = productData.penaltyToIncomeAccountMappings;
this.chargeOffReasonToExpenseAccountMappings = productData.chargeOffReasonToExpenseAccountMappings;
+ this.writeOffReasonsToExpenseMappings = productData.writeOffReasonsToExpenseMappings;
+ this.writeOffReasonOptions = writeOffReasonOptions;
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 8ed7066..5e29e5c 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
@@ -140,6 +140,7 @@
this.loanProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargeOffReasonToExpenseAccountMappings(command, element, loanProductId, null);
+ this.loanProductToGLAccountMappingHelper.saveWriteOffReasonToExpenseAccountMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveBuyDownFeeClassificationToIncomeAccountMappings(command, element,
loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveCapitalizedIncomeClassificationToIncomeAccountMappings(command, element,
@@ -237,6 +238,7 @@
this.loanProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargeOffReasonToExpenseAccountMappings(command, element, loanProductId, null);
+ this.loanProductToGLAccountMappingHelper.saveWriteOffReasonToExpenseAccountMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveBuyDownFeeClassificationToIncomeAccountMappings(command, element,
loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveCapitalizedIncomeClassificationToIncomeAccountMappings(command, element,
@@ -419,6 +421,8 @@
this.loanProductToGLAccountMappingHelper.updateChargesToIncomeAccountMappings(command, element, loanProductId, changes);
this.loanProductToGLAccountMappingHelper.updateChargeOffReasonToExpenseAccountMappings(command, element, loanProductId,
changes);
+
+ this.loanProductToGLAccountMappingHelper.updateWriteOffReasonToExpenseAccountMappings(command, element, loanProductId, changes);
this.loanProductToGLAccountMappingHelper.updateBuyDownFeeClassificationToIncomeAccountMappings(command, element, loanProductId,
changes);
this.loanProductToGLAccountMappingHelper.updateCapitalizedIncomeClassificationToIncomeAccountMappings(command, element,
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 6c981ba..70afeda 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
@@ -53,6 +53,7 @@
import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.data.ClassificationToGLAccountData;
import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper;
+import org.apache.fineract.accounting.producttoaccountmapping.data.WriteOffReasonsToExpenseAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
@@ -355,6 +356,7 @@
Collection<ChargeToGLAccountMapper> feeToGLAccountMappings;
Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings;
List<ChargeOffReasonToGLAccountMapper> chargeOffReasonToGLAccountMappings;
+ List<WriteOffReasonsToExpenseAccountMapper> writeOffReasonsToExpenseAccountMappings;
List<ClassificationToGLAccountData> capitalizedIncomeClassificationToGLAccountMappings;
List<ClassificationToGLAccountData> buydowFeeClassificationToGLAccountMappings;
if (loanProduct.hasAccountingEnabled()) {
@@ -367,6 +369,8 @@
.fetchPenaltyToIncomeAccountMappingsForLoanProduct(productId);
chargeOffReasonToGLAccountMappings = this.accountMappingReadPlatformService
.fetchChargeOffReasonMappingsForLoanProduct(productId);
+ writeOffReasonsToExpenseAccountMappings = this.accountMappingReadPlatformService
+ .fetchWriteOffReasonMappingsForLoanProduct(productId);
capitalizedIncomeClassificationToGLAccountMappings = accountMappingReadPlatformService
.fetchClassificationMappingsForLoanProduct(productId,
LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS);
@@ -374,7 +378,8 @@
productId, LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS);
loanProduct = LoanProductData.withAccountingDetails(loanProduct, accountingMappings, paymentChannelToFundSourceMappings,
feeToGLAccountMappings, penaltyToGLAccountMappings, chargeOffReasonToGLAccountMappings,
- capitalizedIncomeClassificationToGLAccountMappings, buydowFeeClassificationToGLAccountMappings);
+ writeOffReasonsToExpenseAccountMappings, capitalizedIncomeClassificationToGLAccountMappings,
+ buydowFeeClassificationToGLAccountMappings);
}
if (settings.isTemplate()) {
@@ -475,6 +480,8 @@
.getValuesAsStringEnumOptionDataList(LoanBuyDownFeeStrategy.class);
final List<StringEnumOptionData> buyDownFeeIncomeTypeOptions = ApiFacingEnum
.getValuesAsStringEnumOptionDataList(LoanBuyDownFeeIncomeType.class);
+ final List<CodeValueData> writeOffReasonOptions = codeValueReadPlatformService
+ .retrieveCodeValuesByCode(LoanApiConstants.WRITEOFFREASONS);
final List<CodeValueData> capitalizedIncomeClassificationOptions = codeValueReadPlatformService
.retrieveCodeValuesByCode(LoanTransactionApiConstants.CAPITALIZED_INCOME_CLASSIFICATION_CODE);
final List<CodeValueData> buydownFeeClassificationOptions = codeValueReadPlatformService
@@ -493,7 +500,7 @@
creditAllocationAllocationTypes, supportedInterestRefundTypesOptions, chargeOffBehaviourOptions, chargeOffReasonOptions,
daysInYearCustomStrategyOptions, capitalizedIncomeCalculationTypeOptions, capitalizedIncomeStrategyOptions,
capitalizedIncomeTypeOptions, buyDownFeeCalculationTypeOptions, buyDownFeeStrategyOptions, buyDownFeeIncomeTypeOptions,
- capitalizedIncomeClassificationOptions, buydownFeeClassificationOptions);
+ writeOffReasonOptions, capitalizedIncomeClassificationOptions, buydownFeeClassificationOptions);
}
}
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 cd99af2..9132f39 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
@@ -33,6 +33,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.function.BiConsumer;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams;
@@ -152,7 +153,9 @@
LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_FEES.getValue(),
LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_PENALTY.getValue(),
LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue(),
- LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(),
+ LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue(),
+ LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID.getValue(),
+ LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(),
LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(),
LoanProductAccountingParams.INCOME_FROM_CAPITALIZATION.getValue(),
LoanProductAccountingParams.DEFERRED_INCOME_LIABILITY.getValue(), LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(),
@@ -748,6 +751,7 @@
validatePaymentChannelFundSourceMappings(baseDataValidator, element);
validateChargeToIncomeAccountMappings(baseDataValidator, element);
validateChargeOffToExpenseMappings(baseDataValidator, element);
+ validateWriteOffToExpenseMappings(baseDataValidator, element);
validateClassificationToIncomeMappings(baseDataValidator, element,
LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS);
validateClassificationToIncomeMappings(baseDataValidator, element,
@@ -2068,54 +2072,79 @@
private void validateChargeOffToExpenseMappings(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
String parameterName = LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue();
+ LoanProductAccountingParams reasonCodeValueId = LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID;
+ String failCode = "chargeOffReason";
+ validateAdditionalAccountMappings(baseDataValidator, element, parameterName, reasonCodeValueId, failCode,
+ productToGLAccountMappingHelper::validateChargeOffMappingsInDatabase);
+ }
+ private void validateWriteOffToExpenseMappings(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
+ String parameterName = LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue();
+ LoanProductAccountingParams reasonCodeValueId = LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID;
+ String failCode = "writeOffReason";
+ validateAdditionalAccountMappings(baseDataValidator, element, parameterName, reasonCodeValueId, failCode,
+ productToGLAccountMappingHelper::validateWriteOffMappingsInDatabase);
+ }
+
+ private void validateAdditionalAccountMappings(DataValidatorBuilder baseDataValidator, JsonElement element, String parameterName,
+ LoanProductAccountingParams reasonCodeValueIdParam, String failCode,
+ BiConsumer<List<ApiParameterError>, List<JsonObject>> additionalMappingValidator) {
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<>();
+ final JsonArray reasonToExpenseMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(parameterName, element);
+ if (reasonToExpenseMappingArray != null && !reasonToExpenseMappingArray.isEmpty()) {
+ Map<Long, Set<Long>> reasonToAccounts = 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);
+ final JsonObject jsonObject = reasonToExpenseMappingArray.get(i).getAsJsonObject();
+
+ final String expenseGlAccountIdString = this.fromApiJsonHelper
+ .extractStringNamed(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), jsonObject);
+ final String reasonCodeValueIdString = this.fromApiJsonHelper.extractStringNamed(reasonCodeValueIdParam.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();
+ .value(expenseGlAccountIdString).notNull().longGreaterThanZero();
+ baseDataValidator.reset().parameter(
+ parameterName + OPENING_SQUARE_BRACKET + i + CLOSING_SQUARE_BRACKET + DOT + reasonCodeValueIdParam.getValue())
+ .value(reasonCodeValueIdString).notNull().longGreaterThanZero();
- // Handle duplicate charge-off reason and GL Account validation
- chargeOffReasonToAccounts.putIfAbsent(chargeOffReasonCodeValueId, new HashSet<>());
- Set<Long> associatedAccounts = chargeOffReasonToAccounts.get(chargeOffReasonCodeValueId);
+ final Long reasonCodeValueId = Long.valueOf(reasonCodeValueIdString);
+ final Long expenseGlAccountId = Long.valueOf(expenseGlAccountIdString);
+ // Handle duplicate reason and GL Account validation
+ reasonToAccounts.putIfAbsent(reasonCodeValueId, new HashSet<>());
+ Set<Long> associatedAccounts = reasonToAccounts.get(reasonCodeValueId);
if (associatedAccounts.contains(expenseGlAccountId)) {
baseDataValidator.reset().parameter(parameterName + OPENING_SQUARE_BRACKET + i + CLOSING_SQUARE_BRACKET)
- .failWithCode("duplicate.chargeOffReason.and.glAccount");
+ .failWithCode("duplicate." + failCode + ".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");
+ .failWithCode("multiple.glAccounts.for." + failCode);
}
// Collect mapping for additional validations
processedMappings.add(jsonObject);
i++;
- } while (i < chargeOffToExpenseMappingArray.size());
+ } while (i < reasonToExpenseMappingArray.size());
// Call the new validation method for additional checks
- productToGLAccountMappingHelper.validateChargeOffMappingsInDatabase(processedMappings);
+ final List<ApiParameterError> validationErrors = new ArrayList<>();
+ productToGLAccountMappingHelper.validateGLAccountInDatabase(validationErrors, processedMappings);
+ if (additionalMappingValidator != null) {
+ additionalMappingValidator.accept(validationErrors, processedMappings);
+ }
+ if (!validationErrors.isEmpty()) {
+ throw new PlatformApiDataValidationException(validationErrors);
+ }
}
}
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java
index 37e9987..4444f42 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java
@@ -606,7 +606,7 @@
loanChargeOffBehaviour.getValueAsStringEnumOptionData(), interestRecognitionOnDisbursementDate,
daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy,
capitalizedIncome, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType,
- merchantBuyDownFee);
+ merchantBuyDownFee, null, null);
}
}
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 7b12aaa..b2c97bf 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
@@ -217,4 +217,5 @@
<include file="parts/0196_add_deleted_and_closed_to_buy_down_fee_balance.xml" relativeToChangelogFile="true" />
<include file="parts/0197_add_deleted_and_closed_to_capitalized_income_balance.xml" relativeToChangelogFile="true" />
<include file="parts/0198_add_classification_id_to_acc_product_mapping.xml" relativeToChangelogFile="true" />
+ <include file="parts/0199_write_off_reason_mapping_loan.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0199_write_off_reason_mapping_loan.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0199_write_off_reason_mapping_loan.xml
new file mode 100644
index 0000000..74cdd5a
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0199_write_off_reason_mapping_loan.xml
@@ -0,0 +1,34 @@
+<?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="17588202536-1" author="fineract">
+ <addColumn tableName="acc_product_mapping">
+ <column name="write_off_reason_id" type="BIGINT">
+ <constraints nullable="true"/>
+ </column>
+ </addColumn>
+ </changeSet>
+
+</databaseChangeLog>
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java
index 47c6414..df8cc9d 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java
@@ -85,6 +85,7 @@
import org.apache.fineract.client.models.PostRolesRequest;
import org.apache.fineract.client.models.PostUsersRequest;
import org.apache.fineract.client.models.PutGlobalConfigurationsRequest;
+import org.apache.fineract.client.models.PutLoanProductsProductIdRequest;
import org.apache.fineract.client.models.PutLoansApprovedAmountRequest;
import org.apache.fineract.client.models.PutLoansApprovedAmountResponse;
import org.apache.fineract.client.models.PutLoansAvailableDisbursementAmountRequest;
@@ -507,6 +508,103 @@
.loanScheduleType(LoanScheduleType.CUMULATIVE.toString());//
}
+ protected PutLoanProductsProductIdRequest update4IProgressive(String name, String shortName, Long delinquencyBucketId) {
+ return new PutLoanProductsProductIdRequest().name(name).shortName(shortName).description("4 installment product - progressive")//
+ .includeInBorrowerCycle(false)//
+ .useBorrowerCycle(false)//
+ .currencyCode("EUR")//
+ .digitsAfterDecimal(2)//
+ .principal(1000.0)//
+ .minPrincipal(100.0)//
+ .maxPrincipal(10000.0)//
+ .numberOfRepayments(4)//
+ .repaymentEvery(1)//
+ .repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L.intValue())//
+ .interestRatePerPeriod(10D)//
+ .minInterestRatePerPeriod(0D)//
+ .maxInterestRatePerPeriod(120D)//
+ .interestRateFrequencyType(InterestRateFrequencyType.YEARS)//
+ .isLinkedToFloatingInterestRates(false)//
+ .isLinkedToFloatingInterestRates(false)//
+ .allowVariableInstallments(false)//
+ .amortizationType(AmortizationType.EQUAL_INSTALLMENTS)//
+ .interestType(InterestType.DECLINING_BALANCE)//
+ .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)//
+ .allowPartialPeriodInterestCalcualtion(false)//
+ .transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)//
+ .paymentAllocation(List.of(createDefaultPaymentAllocation("NEXT_INSTALLMENT")))//
+ .creditAllocation(List.of())//
+ .overdueDaysForNPA(179)//
+ .daysInMonthType(30L)//
+ .daysInYearType(360L)//
+ .isInterestRecalculationEnabled(true)//
+ .interestRecalculationCompoundingMethod(0)//
+ .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)//
+ .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)//
+ .recalculationRestFrequencyInterval(1)//
+ .isArrearsBasedOnOriginalSchedule(false)//
+ .isCompoundingToBePostedAsTransaction(false)//
+ .preClosureInterestCalculationStrategy(1)//
+ .allowCompoundingOnEod(false)//
+ .canDefineInstallmentAmount(true)//
+ .repaymentStartDateType(1)//
+ .charges(List.of())//
+ .principalVariationsForBorrowerCycle(List.of())//
+ .interestRateVariationsForBorrowerCycle(List.of())//
+ .numberOfRepaymentVariationsForBorrowerCycle(List.of())//
+ .accountingRule(3)//
+ .canUseForTopup(false)//
+ .fundSourceAccountId(fundSource.getAccountID().longValue())//
+ .loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue())//
+ .transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())//
+ .interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue())//
+ .incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue())//
+ .incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue())//
+ .incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue())//
+ .writeOffAccountId(writtenOffAccount.getAccountID().longValue())//
+ .overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())//
+ .receivableInterestAccountId(interestReceivableAccount.getAccountID().longValue())//
+ .receivableFeeAccountId(feeReceivableAccount.getAccountID().longValue())//
+ .receivablePenaltyAccountId(penaltyReceivableAccount.getAccountID().longValue())//
+ .goodwillCreditAccountId(goodwillExpenseAccount.getAccountID().longValue())//
+ .incomeFromGoodwillCreditInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())//
+ .incomeFromGoodwillCreditFeesAccountId(feeChargeOffAccount.getAccountID().longValue())//
+ .incomeFromGoodwillCreditPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue())//
+ .incomeFromChargeOffInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())//
+ .incomeFromChargeOffFeesAccountId(feeChargeOffAccount.getAccountID().longValue())//
+ .incomeFromChargeOffPenaltyAccountId(penaltyChargeOffAccount.getAccountID().longValue())//
+ .chargeOffExpenseAccountId(chargeOffExpenseAccount.getAccountID().longValue())//
+ .chargeOffFraudExpenseAccountId(chargeOffFraudExpenseAccount.getAccountID().longValue())//
+ .dateFormat(DATETIME_PATTERN)//
+ .locale("en")//
+ .enableAccrualActivityPosting(false)//
+ .multiDisburseLoan(true)//
+ .maxTrancheCount(10)//
+ .outstandingLoanBalance(10000.0)//
+ .disallowExpectedDisbursements(true)//
+ .allowApprovedDisbursedAmountsOverApplied(true)//
+ .overAppliedCalculationType("percentage")//
+ .overAppliedNumber(50)//
+ .principalThresholdForLastInstallment(50)//
+ .holdGuaranteeFunds(false)//
+ .accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
+ .allowAttributeOverrides(new AllowAttributeOverrides()//
+ .amortizationType(true)//
+ .interestType(true)//
+ .transactionProcessingStrategyCode(true)//
+ .interestCalculationPeriodType(true)//
+ .inArrearsTolerance(true)//
+ .repaymentEvery(true)//
+ .graceOnPrincipalAndInterestPayment(true)//
+ .graceOnArrearsAgeing(true)//
+ ).isEqualAmortization(false)//
+ .delinquencyBucketId(delinquencyBucketId)//
+ .enableDownPayment(false)//
+ .enableInstallmentLevelDelinquency(false)//
+ .loanScheduleType("PROGRESSIVE")//
+ .loanScheduleProcessingType("HORIZONTAL");
+ }
+
protected PostLoanProductsRequest create4IProgressive() {
final Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec);
Assertions.assertNotNull(delinquencyBucketId);
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTest.java
index 28c144f..060da1b 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTest.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductTest.java
@@ -19,13 +19,23 @@
package org.apache.fineract.integrationtests;
import java.math.BigDecimal;
+import java.util.List;
+import java.util.Objects;
+import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.GetLoanProductsProductIdResponse;
+import org.apache.fineract.client.models.GetLoanProductsTemplateResponse;
+import org.apache.fineract.client.models.GetLoanProductsWriteOffReasonOptions;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostClientsResponse;
+import org.apache.fineract.client.models.PostCodeValueDataResponse;
+import org.apache.fineract.client.models.PostCodeValuesDataRequest;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
+import org.apache.fineract.client.models.PostWriteOffReasonToExpenseAccountMappings;
import org.apache.fineract.client.models.PutLoanProductsProductIdRequest;
import org.apache.fineract.integrationtests.common.ClientHelper;
+import org.apache.fineract.integrationtests.common.FineractClientHelper;
+import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeCalculationType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeIncomeType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeStrategy;
@@ -36,6 +46,7 @@
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
+@Slf4j
public class LoanProductTest extends BaseLoanIntegrationTest {
@Nested
@@ -458,4 +469,118 @@
.buyDownExpenseAccountId(buyDownExpenseAccount.getAccountID().longValue())));
}
}
+
+ @Nested
+ public class WriteOffReasonsToExpenseMappings {
+
+ @Test
+ public void testWriteOffReasonToExpenseAccountMapping_shouldFail_on_nonExistingGLAccount_And_nonExistingWriteOffReason() {
+ try {
+ loanProductHelper.createLoanProduct(
+ create4IProgressive().addWriteOffReasonsToExpenseMappingsItem(new PostWriteOffReasonToExpenseAccountMappings()
+ .expenseAccountId("101230023").writeOffReasonCodeValueId("201230023")));
+ Assertions.fail("Should have thrown an IllegalArgumentException");
+ } catch (final RuntimeException ex) {
+ Assertions.assertTrue(
+ ex.getMessage().contains("GL Account with ID 101230023 does not exist or is not an Expense GL account"));
+ Assertions.assertTrue(ex.getMessage().contains("Write-off reason with ID 201230023 does not exist"));
+ }
+ }
+
+ @Test
+ public void testWriteOffReasonToExpenseAccountMapping_shouldFail_on_nonExistingGLAccount_And_Invalid_expenseAccountId() {
+ try {
+ loanProductHelper.createLoanProduct(create4IProgressive().addWriteOffReasonsToExpenseMappingsItem(
+ new PostWriteOffReasonToExpenseAccountMappings().expenseAccountId("asdf323").writeOffReasonCodeValueId("111")));
+ Assertions.fail("Should have thrown an IllegalArgumentException");
+ } catch (final RuntimeException ex) {
+ Assertions.assertTrue(ex.getMessage()
+ .contains("validation.msg.loanproduct.writeOffReasonsToExpenseMappings[0].expenseAccountId.not.a.number"));
+ Assertions.assertTrue(
+ ex.getMessage().contains("The parameter `writeOffReasonsToExpenseMappings[0].expenseAccountId` must be a number."));
+ }
+ }
+
+ @Test
+ public void testWriteOffReasonToExpenseAccountMapping_shouldFail_on_nonExistingGLAccount_And_Invalid_writeOffReasonCodeValueId() {
+ try {
+ loanProductHelper.createLoanProduct(create4IProgressive().addWriteOffReasonsToExpenseMappingsItem(
+ new PostWriteOffReasonToExpenseAccountMappings().expenseAccountId("111").writeOffReasonCodeValueId("asdf323")));
+ Assertions.fail("Should have thrown an IllegalArgumentException");
+ } catch (final RuntimeException ex) {
+ log.info("Exception: {}", ex.getMessage());
+ Assertions.assertTrue(ex.getMessage()
+ .contains("validation.msg.loanproduct.writeOffReasonsToExpenseMappings[0].writeOffReasonCodeValueId.not.a.number"));
+ Assertions.assertTrue(ex.getMessage()
+ .contains("The parameter `writeOffReasonsToExpenseMappings[0].writeOffReasonCodeValueId` must be a number."));
+ }
+ }
+
+ @Test
+ public void testWriteOffReasonsToExpenseMappings() {
+
+ // create Write Off reasons
+ Long reasonCode1 = createTestWriteOffReason();
+ Long reasonCode2 = createTestWriteOffReason();
+
+ // check if write Off reasons appears on loan product template
+ GetLoanProductsTemplateResponse loanProductTemplate = loanProductHelper.getLoanProductTemplate(false);
+ List<GetLoanProductsWriteOffReasonOptions> writeOffReasonOptions = loanProductTemplate.getWriteOffReasonOptions();
+ Assertions.assertNotNull(writeOffReasonOptions);
+
+ boolean isReasonCode1InTemplate = writeOffReasonOptions.stream().map(GetLoanProductsWriteOffReasonOptions::getId)
+ .anyMatch(id -> Objects.equals(id, reasonCode1));
+ boolean isReasonCode2InTemplate = writeOffReasonOptions.stream().map(GetLoanProductsWriteOffReasonOptions::getId)
+ .anyMatch(id -> Objects.equals(id, reasonCode2));
+ Assertions.assertTrue(isReasonCode1InTemplate);
+ Assertions.assertTrue(isReasonCode2InTemplate);
+
+ // Create Test Loan Product
+ String reasonCodeId = reasonCode1.toString();
+ String expenseAccountId = buyDownExpenseAccount.getAccountID().toString();
+
+ Long loanProductId = loanProductHelper.createLoanProduct(
+ create4IProgressive().addWriteOffReasonsToExpenseMappingsItem(new PostWriteOffReasonToExpenseAccountMappings()
+ .expenseAccountId(expenseAccountId).writeOffReasonCodeValueId(reasonCodeId)))
+ .getResourceId();
+
+ // Verify that get loan product API has the corresponding fields
+ GetLoanProductsProductIdResponse getLoanProductsProductIdResponse = loanProductHelper.retrieveLoanProductById(loanProductId);
+ List<PostWriteOffReasonToExpenseAccountMappings> writeOffReasonToExpenseAccountMappings = getLoanProductsProductIdResponse
+ .getWriteOffReasonsToExpenseMappings();
+ Assertions.assertNotNull(writeOffReasonToExpenseAccountMappings);
+ Assertions.assertEquals(1, writeOffReasonToExpenseAccountMappings.size());
+ PostWriteOffReasonToExpenseAccountMappings writeOffMapping = writeOffReasonToExpenseAccountMappings.getFirst();
+ Assertions.assertNotNull(writeOffMapping);
+ Assertions.assertEquals(expenseAccountId, writeOffMapping.getExpenseAccountId());
+ Assertions.assertEquals(reasonCodeId, writeOffMapping.getWriteOffReasonCodeValueId());
+
+ List<GetLoanProductsWriteOffReasonOptions> writeOffReasonOptionsResultNonTemplate = getLoanProductsProductIdResponse
+ .getWriteOffReasonOptions();
+ if (writeOffReasonOptionsResultNonTemplate != null && !writeOffReasonOptionsResultNonTemplate.isEmpty()) {
+ Assertions.fail("Write-off reason options with no template setting should be empty");
+ }
+
+ // test Update loan product API - delete writeOffReasonsToExpenseMappings
+
+ GetLoanProductsProductIdResponse getLoanProductsProductId = loanProductHelper.retrieveLoanProductById(loanProductId);
+
+ loanProductHelper.updateLoanProductById(loanProductId,
+ update4IProgressive(getLoanProductsProductId.getName(), getLoanProductsProductId.getShortName(),
+ getLoanProductsProductId.getDelinquencyBucket().getId()).writeOffReasonsToExpenseMappings(List.of()));
+
+ // Verify that get loan product API has the corresponding fields
+ Assertions.assertNull(loanProductHelper.retrieveLoanProductById(loanProductId).getWriteOffReasonsToExpenseMappings());
+ }
+ }
+
+ private Long createTestWriteOffReason() {
+ PostCodeValueDataResponse response = okR(FineractClientHelper.getFineractClient().codeValues.createCodeValue(26L,
+ new PostCodeValuesDataRequest().name(Utils.uniqueRandomStringGenerator("TestWriteOffReason_1_", 6))
+ .description("Test write off reason value 1").isActive(true).position(0)))
+ .body();
+ Assertions.assertNotNull(response);
+ Assertions.assertNotNull(response.getSubResourceId());
+ return response.getSubResourceId();
+ }
}
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 43b34bf..0ae33fa 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
@@ -834,7 +834,7 @@
}
Map<String, Long> newMap = new HashMap<>();
newMap.put("chargeOffReasonCodeValueId", reasonId);
- newMap.put("expenseGLAccountId", accountId);
+ newMap.put("expenseAccountId", accountId);
this.chargeOffReasonToExpenseAccountMappings.add(newMap);
return this;
}