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 12edabfae..34442ba21 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 @@ -68,6 +68,10 @@ public class ProductToGLAccountMapping extends AbstractPersistableCustom { @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; 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 917fbdffd..0ad7c3d15 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/domain/ProductToGLAccountMappingRepository.java @@ -35,7 +35,7 @@ public interface ProductToGLAccountMappingRepository @Param("productType") int productType, @Param("financialAccountType") int financialAccountType, @Param("chargeId") Long ChargeId); - @Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.financialAccountType=:financialAccountType and mapping.paymentType is NULL and mapping.charge is NULL 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 findAllChargeOffReasonsMappings(@Param("productId") Long productId, @Param("productType") int productType); + List 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 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 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 dfbd94c52..059ac4845 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.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 changes, final PortfolioProductType portfolioProductType) { + public void saveReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId, + final Map 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 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 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 changes, final PortfolioProductType portfolioProductType, + final List existingReasonToGLAccountMappings, + final LoanProductAccountingParams reasonToExpenseAccountMappingsParam, final LoanProductAccountingParams reasonCodeValueIdParam, + final CashAccountsForLoan cashAccountsForLoan) { - final Map inputChargeOffReasonToGLAccountMap = new HashMap<>(); + final JsonArray reasonToGLAccountMappingArray = this.fromApiJsonHelper + .extractJsonArrayNamed(reasonToExpenseAccountMappingsParam.getValue(), element); - final Set existingChargeOffReasons = new HashSet<>(); - if (chargeOffReasonToGLAccountMappingArray != null) { + final Map inputReasonToGLAccountMap = new HashMap<>(); + + final Set 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 = 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 entry : inputChargeOffReasonToGLAccountMap.entrySet().stream() - .filter(e -> !existingChargeOffReasons.contains(e.getKey())).toList()) { - saveChargeOffReasonToExpenseMapping(productId, entry.getKey(), entry.getValue(), portfolioProductType); + for (Map.Entry 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 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 = accountRepository.findById(expenseAccountId); - - final boolean reasonMappingExists = this.accountMappingRepository - .findAllChargeOffReasonsMappings(productId, portfolioProductType.getValue()).stream() - .anyMatch(mapping -> mapping.getChargeOffReason().getId().equals(reasonId)); - final Optional 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 mappings) { - final List validationErrors = new ArrayList<>(); + public void validateWriteOffMappingsInDatabase(final List validationErrors, final List 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 validationErrors, final List 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 = accountRepository.findById(expenseGlAccountId); @@ -730,6 +766,22 @@ public class ProductToGLAccountMappingHelper { } } + } + + public void validateChargeOffMappingsInDatabase(final List validationErrors, final List 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 22ccc37c1..d7af51f08 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.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 fetchChargeOffReasonMappingsForLoanProduct(Long loanProductId); + List fetchWriteOffReasonMappingsForLoanProduct(Long loanProductId); + List 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 1fcb7bf6d..078b2b810 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.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 fetchWriteOffReasonMappings(final PortfolioProductType portfolioProductType, + final Long loanProductId) { + final List mappings = productToGLAccountMappingRepository.findAllWriteOffReasonsMappings(loanProductId, + portfolioProductType.getValue()); + List 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 fetchClassificationMappings(final PortfolioProductType portfolioProductType, final Long loanProductId, LoanProductAccountingParams classificationParameter) { final List mappings = classificationParameter @@ -367,6 +384,11 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product return fetchChargeOffReasonMappings(PortfolioProductType.LOAN, loanProductId); } + @Override + public List fetchWriteOffReasonMappingsForLoanProduct(Long loanProductId) { + return fetchWriteOffReasonMappings(PortfolioProductType.LOAN, loanProductId); + } + @Override public List fetchClassificationMappingsForLoanProduct(Long loanProductId, LoanProductAccountingParams 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 7435a46d9..b05b2d6ae 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 @@ 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"), // 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 000000000..df49b8e1b --- /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 13ab2a2ea..253b4a857 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 @@ 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; 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 80b64277e..ecffdb7db 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.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 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 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 changes) { + final List 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 changes) { - updateChargeOffReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN); + final List 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 ccb47b2ff..79c007bb5 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 final class LoanProductsApiResourceSwagger { public List paymentChannelToFundSourceMappings; public List feeToIncomeAccountMappings; public List chargeOffReasonToExpenseAccountMappings; + public List writeOffReasonsToExpenseMappings; public List buydownfeeClassificationToIncomeAccountMappings; public List capitalizedIncomeClassificationToIncomeAccountMappings; public List 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 supportedInterestRefundTypes; public List supportedInterestRefundTypesOptions; public List chargeOffReasonOptions; + public List writeOffReasonOptions; public StringEnumOptionData chargeOffBehaviour; public List 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 paymentChannelToFundSourceMappings; public Set feeToIncomeAccountMappings; public List chargeOffReasonToExpenseAccountMappings; + public List writeOffReasonsToExpenseMappings; @Schema(example = "false") public Boolean isRatesEnabled; @Schema(example = "true") @@ -1511,6 +1536,7 @@ public final class LoanProductsApiResourceSwagger { public Boolean enableAccrualActivityPosting; public List supportedInterestRefundTypes; public List chargeOffReasonOptions; + public List writeOffReasonOptions; public StringEnumOptionData chargeOffBehaviour; @Schema(example = "false") public Boolean interestRecognitionOnDisbursementDate; @@ -1774,6 +1800,7 @@ public final class LoanProductsApiResourceSwagger { public List paymentChannelToFundSourceMappings; public List feeToIncomeAccountMappings; public List chargeOffReasonToExpenseAccountMappings; + public List writeOffReasonsToExpenseMappings; public List buydownfeeClassificationToIncomeAccountMappings; public List capitalizedIncomeClassificationToIncomeAccountMappings; public List 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; + } + } 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 87660df6d..23066f3af 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.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 penaltyToIncomeAccountMappings; private List chargeOffReasonToExpenseAccountMappings; private final boolean enableAccrualActivityPosting; - + private List writeOffReasonsToExpenseMappings; + private final List writeOffReasonOptions; // rates private final boolean isRatesEnabled; private final Collection rates; @@ -379,6 +381,8 @@ public class LoanProductData implements Serializable { final StringEnumOptionData buyDownFeeStrategy = null; final StringEnumOptionData buyDownFeeIncomeType = null; final boolean merchantBuyDownFee = false; + final List writeOffReasonsToExpenseMappings = null; + final List 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 writeOffReasonsToExpenseMappings = null; + final List 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 writeOffReasonsToExpenseMappings = null; + final List 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 writeOffReasonsToExpenseMappings = null; + final List 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 accountingMappings, @@ -829,6 +843,7 @@ public class LoanProductData implements Serializable { final Collection feeToGLAccountMappings, final Collection penaltyToGLAccountMappings, final List chargeOffReasonToGLAccountMappings, + final List writeOffReasonToGLAccountMappings, final List capitalizedIncomeClassificationToIncomeAccountMappings, final List 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 writeOffReasonsToExpenseMappings, + final List 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 capitalizedIncomeStrategyOptions, final List capitalizedIncomeTypeOptions, final List buyDownFeeCalculationTypeOptions, final List buyDownFeeStrategyOptions, - final List buyDownFeeIncomeTypeOptions, final List capitalizedIncomeClassificationOptions, - final List buydownFeeClassificationOptions) { + final List buyDownFeeIncomeTypeOptions, final List writeOffReasonOptions, + final List capitalizedIncomeClassificationOptions, final List 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; 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 8ed70663e..5e29e5ca1 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 @@ 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, 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 6c981ba69..70afeda88 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.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 feeToGLAccountMappings; Collection penaltyToGLAccountMappings; List chargeOffReasonToGLAccountMappings; + List writeOffReasonsToExpenseAccountMappings; List capitalizedIncomeClassificationToGLAccountMappings; List 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 buyDownFeeIncomeTypeOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(LoanBuyDownFeeIncomeType.class); + final List writeOffReasonOptions = codeValueReadPlatformService + .retrieveCodeValuesByCode(LoanApiConstants.WRITEOFFREASONS); final List capitalizedIncomeClassificationOptions = codeValueReadPlatformService .retrieveCodeValuesByCode(LoanTransactionApiConstants.CAPITALIZED_INCOME_CLASSIFICATION_CODE); final List 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); } } 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 cd99af21d..9132f39f6 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.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> additionalMappingValidator) { if (this.fromApiJsonHelper.parameterExists(parameterName, element)) { - final JsonArray chargeOffToExpenseMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(parameterName, element); - if (chargeOffToExpenseMappingArray != null && chargeOffToExpenseMappingArray.size() > 0) { - Map> chargeOffReasonToAccounts = new HashMap<>(); + final JsonArray reasonToExpenseMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(parameterName, element); + if (reasonToExpenseMappingArray != null && !reasonToExpenseMappingArray.isEmpty()) { + Map> reasonToAccounts = new HashMap<>(); List processedMappings = new ArrayList<>(); // Collect processed mappings for the new method int i = 0; do { - final JsonObject jsonObject = chargeOffToExpenseMappingArray.get(i).getAsJsonObject(); - final Long expenseGlAccountId = this.fromApiJsonHelper - .extractLongNamed(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), jsonObject); - final Long chargeOffReasonCodeValueId = this.fromApiJsonHelper - .extractLongNamed(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(), jsonObject); + 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 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 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 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 37e9987e2..4444f42ad 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 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo 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 7b12aaab7..b2c97bf23 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 @@ + 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 000000000..74cdd5aea --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0199_write_off_reason_mapping_loan.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + 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 47c64147b..df8cc9d84 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.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); 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 28c144f26..060da1bc6 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.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 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 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 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 43b34bf85..0ae33fa69 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 @@ public class LoanProductTestBuilder { } Map newMap = new HashMap<>(); newMap.put("chargeOffReasonCodeValueId", reasonId); - newMap.put("expenseGLAccountId", accountId); + newMap.put("expenseAccountId", accountId); this.chargeOffReasonToExpenseAccountMappings.add(newMap); return this; }