FINERACT-2358: Allow to configure advanced accounting rules based on write-off reason

This commit is contained in:
Soma Sörös
2025-10-01 10:29:03 +02:00
committed by Adam Saghy
parent 641f755193
commit d6323be7dd
20 changed files with 635 additions and 106 deletions
@@ -68,6 +68,10 @@ public class ProductToGLAccountMapping extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "charge_off_reason_id", nullable = true)
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;
@@ -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 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 @@ public interface ProductToGLAccountMappingRepository
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")
@@ -29,6 +29,7 @@ import java.util.Map;
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 class ProductToGLAccountMappingHelper {
}
}
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 class ProductToGLAccountMappingHelper {
}
}
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 @@ public class ProductToGLAccountMappingHelper {
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 class ProductToGLAccountMappingHelper {
}
}
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 class ProductToGLAccountMappingHelper {
}
}
}
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()) {
@@ -25,6 +25,7 @@ import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeOffReas
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 @@ public interface ProductToGLAccountMappingReadPlatformService {
List<ChargeOffReasonToGLAccountMapper> fetchChargeOffReasonMappingsForLoanProduct(Long loanProductId);
List<WriteOffReasonsToExpenseAccountMapper> fetchWriteOffReasonMappingsForLoanProduct(Long loanProductId);
List<ClassificationToGLAccountData> fetchClassificationMappingsForLoanProduct(Long loanProductId,
LoanProductAccountingParams classificationParameter);
}
@@ -40,6 +40,7 @@ import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeOffReas
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 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
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
@@ -367,6 +384,11 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
return fetchChargeOffReasonMappings(PortfolioProductType.LOAN, loanProductId);
}
@Override
public List<WriteOffReasonsToExpenseAccountMapper> fetchWriteOffReasonMappingsForLoanProduct(Long loanProductId) {
return fetchWriteOffReasonMappings(PortfolioProductType.LOAN, loanProductId);
}
@Override
public List<ClassificationToGLAccountData> fetchClassificationMappingsForLoanProduct(Long loanProductId,
LoanProductAccountingParams classificationParameter) {
@@ -181,8 +181,10 @@ public final class AccountingConstants {
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"), //
@@ -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;
}
@@ -531,13 +531,20 @@ public class DataValidatorBuilder {
}
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;
@@ -30,6 +30,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccount;
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 class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
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,
@@ -301,6 +301,7 @@ public final class LoanProductsApiResourceSwagger {
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 @@ public final class LoanProductsApiResourceSwagger {
@Schema(example = "REGULAR")
public String chargeOffBehaviour;
static final class PostChargeOffReasonToExpenseAccountMappings {
public static final class PostChargeOffReasonToExpenseAccountMappings {
private PostChargeOffReasonToExpenseAccountMappings() {}
@@ -382,6 +383,17 @@ public final class LoanProductsApiResourceSwagger {
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 final class LoanProductsApiResourceSwagger {
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 final class LoanProductsApiResourceSwagger {
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 final class LoanProductsApiResourceSwagger {
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 final class LoanProductsApiResourceSwagger {
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 final class LoanProductsApiResourceSwagger {
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 @@ public final class LoanProductsApiResourceSwagger {
@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;
}
}
@@ -34,6 +34,7 @@ import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeOffReas
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
productData.feeToIncomeAccountMappings = feeToGLAccountMappings;
productData.penaltyToIncomeAccountMappings = penaltyToGLAccountMappings;
productData.chargeOffReasonToExpenseAccountMappings = chargeOffReasonToGLAccountMappings;
productData.writeOffReasonsToExpenseMappings = writeOffReasonToGLAccountMappings;
productData.capitalizedIncomeClassificationToIncomeAccountMappings = capitalizedIncomeClassificationToIncomeAccountMappings;
productData.buydownFeeClassificationToIncomeAccountMappings = buydownFeeClassificationToIncomeAccountMappings;
return productData;
@@ -884,7 +900,9 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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 @@ public class LoanProductData implements Serializable {
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;
@@ -140,6 +140,7 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
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 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
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 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
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,
@@ -53,6 +53,7 @@ import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeOffReas
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 @@ public class LoanProductsApiResource {
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 @@ public class LoanProductsApiResource {
.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 @@ public class LoanProductsApiResource {
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 @@ public class LoanProductsApiResource {
.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 @@ public class LoanProductsApiResource {
creditAllocationAllocationTypes, supportedInterestRefundTypesOptions, chargeOffBehaviourOptions, chargeOffReasonOptions,
daysInYearCustomStrategyOptions, capitalizedIncomeCalculationTypeOptions, capitalizedIncomeStrategyOptions,
capitalizedIncomeTypeOptions, buyDownFeeCalculationTypeOptions, buyDownFeeStrategyOptions, buyDownFeeIncomeTypeOptions,
capitalizedIncomeClassificationOptions, buydownFeeClassificationOptions);
writeOffReasonOptions, capitalizedIncomeClassificationOptions, buydownFeeClassificationOptions);
}
}
@@ -33,6 +33,7 @@ import java.util.List;
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 @@ public final class LoanProductDataValidator {
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 @@ public final class LoanProductDataValidator {
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 @@ public final class LoanProductDataValidator {
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);
}
}
}
}
@@ -606,7 +606,7 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo
loanChargeOffBehaviour.getValueAsStringEnumOptionData(), interestRecognitionOnDisbursementDate,
daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy,
capitalizedIncome, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType,
merchantBuyDownFee);
merchantBuyDownFee, null, null);
}
}
@@ -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>
@@ -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>
@@ -85,6 +85,7 @@ import org.apache.fineract.client.models.PostLoansResponse;
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 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest {
.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);
@@ -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.Assertions;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@Slf4j
public class LoanProductTest extends BaseLoanIntegrationTest {
@Nested
@@ -458,4 +469,118 @@ public class LoanProductTest extends BaseLoanIntegrationTest {
.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();
}
}
@@ -834,7 +834,7 @@ public class LoanProductTestBuilder {
}
Map<String, Long> newMap = new HashMap<>();
newMap.put("chargeOffReasonCodeValueId", reasonId);
newMap.put("expenseGLAccountId", accountId);
newMap.put("expenseAccountId", accountId);
this.chargeOffReasonToExpenseAccountMappings.add(newMap);
return this;
}