FINERACT-2374: Advance Accounting rule for classification type

This commit is contained in:
Jose Alberto Hernandez
2025-09-30 12:27:45 +02:00
committed by Adam Saghy
parent 6d3e5fa150
commit cb70555485
31 changed files with 1023 additions and 66 deletions
@@ -68,10 +68,20 @@ public class ProductToGLAccountMapping extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "charge_off_reason_id", nullable = true)
private CodeValue chargeOffReason;
@ManyToOne
@JoinColumn(name = "capitalized_income_classification_id", nullable = true)
private CodeValue capitalizedIncomeClassification;
@ManyToOne
@JoinColumn(name = "buydown_fee_classification_id", nullable = true)
private CodeValue buydownFeeClassification;
public static ProductToGLAccountMapping createNew(final GLAccount glAccount, final Long productId, final int productType,
final int financialAccountType, final CodeValue chargeOffReason) {
final int financialAccountType, final CodeValue chargeOffReason, final CodeValue capitalizedIncomeClassification,
final CodeValue buydownFeeClassification) {
return new ProductToGLAccountMapping().setGlAccount(glAccount).setProductId(productId).setProductType(productType)
.setFinancialAccountType(financialAccountType).setChargeOffReason(chargeOffReason);
.setFinancialAccountType(financialAccountType).setChargeOffReason(chargeOffReason)
.setCapitalizedIncomeClassification(capitalizedIncomeClassification).setBuydownFeeClassification(buydownFeeClassification);
}
}
@@ -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")
@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")
ProductToGLAccountMapping findCoreProductToFinAccountMapping(@Param("productId") Long productId, @Param("productType") int productType,
@Param("financialAccountType") int financialAccountType);
@@ -70,7 +70,7 @@ public interface ProductToGLAccountMappingRepository
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")
@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")
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")
@@ -82,4 +82,21 @@ public interface ProductToGLAccountMappingRepository
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId AND mapping.productType =:productType AND mapping.charge.penalty = FALSE")
List<ProductToGLAccountMapping> findAllFeeMappings(@Param("productId") Long productId, @Param("productType") Integer productType);
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.capitalizedIncomeClassification is not NULL")
List<ProductToGLAccountMapping> findAllCapitalizedIncomeClassificationsMappings(@Param("productId") Long productId,
@Param("productType") int productType);
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.buydownFeeClassification is not NULL")
List<ProductToGLAccountMapping> findAllBuyDownFeeClassificationsMappings(@Param("productId") Long productId,
@Param("productType") int productType);
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.buydownFeeClassification.id = :classificationId AND mapping.productId = :productId AND mapping.productType = :productType")
ProductToGLAccountMapping findBuydownFeeClassificationMapping(@Param("productId") Long productId,
@Param("productType") Integer productType, @Param("classificationId") Long classificationId);
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.capitalizedIncomeClassification.id = :classificationId AND mapping.productId = :productId AND mapping.productType = :productType")
ProductToGLAccountMapping findCapitalizedIncomeClassificationMapping(@Param("productId") Long productId,
@Param("productType") Integer productType, @Param("classificationId") Long classificationId);
}
@@ -227,6 +227,30 @@ public class ProductToGLAccountMappingHelper {
}
}
public void saveClassificationToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes, final PortfolioProductType portfolioProductType,
final LoanProductAccountingParams classificationParameter) {
final String arrayName = classificationParameter.getValue();
final JsonArray classificationToIncomeAccountMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(arrayName, element);
if (classificationToIncomeAccountMappingArray != null) {
if (changes != null) {
changes.put(arrayName, command.jsonFragment(arrayName));
}
for (int i = 0; i < classificationToIncomeAccountMappingArray.size(); i++) {
final JsonObject jsonObject = classificationToIncomeAccountMappingArray.get(i).getAsJsonObject();
final Long classificationId = jsonObject.get(LoanProductAccountingParams.CLASSIFICATION_CODE_VALUE_ID.getValue())
.getAsLong();
final Long incomeAccountId = jsonObject.get(LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue()).getAsLong();
saveClassificationToIncomeMapping(productId, classificationId, incomeAccountId, portfolioProductType,
classificationParameter);
}
}
}
/**
* @param command
* @param element
@@ -448,6 +472,75 @@ public class ProductToGLAccountMappingHelper {
}
}
public void updateClassificationToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes, final PortfolioProductType portfolioProductType,
final LoanProductAccountingParams classificationParameter) {
final List<ProductToGLAccountMapping> existingClassificationToGLAccountMappings = classificationParameter
.equals(LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS)
? this.accountMappingRepository.findAllCapitalizedIncomeClassificationsMappings(productId,
portfolioProductType.getValue())
: this.accountMappingRepository.findAllBuyDownFeeClassificationsMappings(productId,
portfolioProductType.getValue());
final JsonArray classificationToGLAccountMappingArray = this.fromApiJsonHelper
.extractJsonArrayNamed(classificationParameter.getValue(), element);
final Map<Long, Long> inputClassificationToGLAccountMap = new HashMap<>();
final Set<Long> existingClassifications = new HashSet<>();
if (classificationToGLAccountMappingArray != null) {
if (changes != null) {
changes.put(classificationParameter.getValue(), command.jsonFragment(classificationParameter.getValue()));
}
for (int i = 0; i < classificationToGLAccountMappingArray.size(); i++) {
final JsonObject jsonObject = classificationToGLAccountMappingArray.get(i).getAsJsonObject();
final Long incomeGlAccountId = jsonObject.get(LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue()).getAsLong();
final Long classificationCodeValueId = jsonObject.get(LoanProductAccountingParams.CLASSIFICATION_CODE_VALUE_ID.getValue())
.getAsLong();
inputClassificationToGLAccountMap.put(classificationCodeValueId, incomeGlAccountId);
}
// If input map is empty, delete all existing mappings
if (inputClassificationToGLAccountMap.isEmpty()) {
this.accountMappingRepository.deleteAllInBatch(existingClassificationToGLAccountMappings);
} else {
for (final ProductToGLAccountMapping existingClassificationToGLAccountMapping : existingClassificationToGLAccountMappings) {
final Long currentClassificationId = classificationParameter
.equals(LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS)
? existingClassificationToGLAccountMapping.getCapitalizedIncomeClassification().getId()
: existingClassificationToGLAccountMapping.getBuydownFeeClassification().getId();
if (currentClassificationId != null) {
existingClassifications.add(currentClassificationId);
// update existing mappings (if required)
if (inputClassificationToGLAccountMap.containsKey(currentClassificationId)) {
final Long newGLAccountId = inputClassificationToGLAccountMap.get(currentClassificationId);
if (!newGLAccountId.equals(existingClassificationToGLAccountMapping.getGlAccount().getId())) {
final Optional<GLAccount> glAccount = accountRepository.findById(newGLAccountId);
if (glAccount.isPresent()) {
existingClassificationToGLAccountMapping.setGlAccount(glAccount.get());
this.accountMappingRepository.saveAndFlush(existingClassificationToGLAccountMapping);
}
}
} // deleted previous record
else {
this.accountMappingRepository.delete(existingClassificationToGLAccountMapping);
}
}
}
// only the newly added
for (Map.Entry<Long, Long> entry : inputClassificationToGLAccountMap.entrySet().stream()
.filter(e -> !existingClassifications.contains(e.getKey())).toList()) {
saveClassificationToIncomeMapping(productId, entry.getKey(), entry.getValue(), portfolioProductType,
classificationParameter);
}
}
}
}
/**
* @param productId
*
@@ -514,6 +607,39 @@ public class ProductToGLAccountMappingHelper {
}
}
private void saveClassificationToIncomeMapping(final Long productId, final Long classificationId, final Long incomeAccountId,
final PortfolioProductType portfolioProductType, final LoanProductAccountingParams classificationParameter) {
final Optional<GLAccount> glAccount = accountRepository.findById(incomeAccountId);
boolean classificationMappingExists = false;
if (classificationParameter.equals(LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS)) {
classificationMappingExists = this.accountMappingRepository
.findAllCapitalizedIncomeClassificationsMappings(productId, portfolioProductType.getValue()).stream()
.anyMatch(mapping -> mapping.getCapitalizedIncomeClassification().getId().equals(classificationId));
} else {
classificationMappingExists = this.accountMappingRepository
.findAllBuyDownFeeClassificationsMappings(productId, portfolioProductType.getValue()).stream()
.anyMatch(mapping -> mapping.getBuydownFeeClassification().getId().equals(classificationId));
}
final Optional<CodeValue> codeValueOptional = codeValueRepository.findById(classificationId);
if (glAccount.isPresent() && !classificationMappingExists && codeValueOptional.isPresent()) {
final ProductToGLAccountMapping accountMapping = new ProductToGLAccountMapping().setGlAccount(glAccount.get())
.setProductId(productId).setProductType(portfolioProductType.getValue())
.setFinancialAccountType(CashAccountsForLoan.CLASSIFICATION_INCOME.getValue());
if (classificationParameter.equals(LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS)) {
accountMapping.setCapitalizedIncomeClassification(codeValueOptional.get());
} else {
accountMapping.setBuydownFeeClassification(codeValueOptional.get());
}
this.accountMappingRepository.saveAndFlush(accountMapping);
}
}
private List<GLAccountType> getAllowedAccountTypesForFeeMapping() {
List<GLAccountType> allowedAccountTypes = new ArrayList<>();
allowedAccountTypes.add(GLAccountType.INCOME);
@@ -610,4 +736,38 @@ public class ProductToGLAccountMappingHelper {
throw new PlatformApiDataValidationException(validationErrors);
}
}
public void validateClassificationMappingsInDatabase(final List<JsonObject> mappings, final String dataCodeName) {
final List<ApiParameterError> validationErrors = new ArrayList<>();
for (JsonObject jsonObject : mappings) {
final Long incomeGlAccountId = this.fromApiJsonHelper.extractLongNamed(LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue(),
jsonObject);
final Long classificationCodeValueId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.CLASSIFICATION_CODE_VALUE_ID.getValue(), jsonObject);
// Validation: classificationCodeValueId must exist in the database
final CodeValue codeValue = this.codeValueRepository.findByCodeNameAndId(dataCodeName, classificationCodeValueId);
if (codeValue == null) {
validationErrors.add(ApiParameterError.parameterError("validation.msg.classification.invalid",
"Classification with ID " + classificationCodeValueId + " does not exist", dataCodeName));
}
// Validation: expenseGLAccountId must exist as a valid Expense GL account
final Optional<GLAccount> glAccount = accountRepository.findById(incomeGlAccountId);
if (glAccount.isEmpty() || !GLAccountType.fromInt(glAccount.get().getType()).isIncomeType()) {
validationErrors.add(ApiParameterError.parameterError("validation.msg.glaccount.not.found",
"GL Account with ID " + incomeGlAccountId + " does not exist or is not an Income GL account",
LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue()));
}
}
// Throw all collected validation errors, if any
if (!validationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(validationErrors);
}
}
}
@@ -20,8 +20,10 @@ package org.apache.fineract.accounting.producttoaccountmapping.service;
import java.util.List;
import java.util.Map;
import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams;
import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeOffReasonToGLAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper;
import org.apache.fineract.accounting.producttoaccountmapping.data.ClassificationToGLAccountData;
import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper;
public interface ProductToGLAccountMappingReadPlatformService {
@@ -49,4 +51,7 @@ public interface ProductToGLAccountMappingReadPlatformService {
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForShareProduct(Long productId);
List<ChargeOffReasonToGLAccountMapper> fetchChargeOffReasonMappingsForLoanProduct(Long loanProductId);
List<ClassificationToGLAccountData> fetchClassificationMappingsForLoanProduct(Long loanProductId,
LoanProductAccountingParams classificationParameter);
}
@@ -30,6 +30,7 @@ import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsFor
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingDataParams;
import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams;
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingDataParams;
import org.apache.fineract.accounting.common.AccountingConstants.SharesProductAccountingParams;
import org.apache.fineract.accounting.common.AccountingRuleType;
@@ -37,14 +38,15 @@ import org.apache.fineract.accounting.common.AccountingValidations;
import org.apache.fineract.accounting.glaccount.data.GLAccountData;
import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeOffReasonToGLAccountMapper;
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.domain.ProductToGLAccountMapping;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
import org.apache.fineract.infrastructure.codes.mapper.CodeValueMapper;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.charge.data.ChargeData;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Slf4j
@@ -52,9 +54,8 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor
public class ProductToGLAccountMappingReadPlatformServiceImpl implements ProductToGLAccountMappingReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
private final CodeValueMapper codeValueMapper;
@Override
public Map<String, Object> fetchAccountMappingDetailsForLoanProduct(final Long loanProductId, final Integer accountingType) {
@@ -283,14 +284,7 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
final String glAccountName = mapping.getGlAccount().getName();
final String glCode = mapping.getGlAccount().getGlCode();
final GLAccountData chargeOffExpenseAccount = new GLAccountData().setId(glAccountId).setName(glAccountName).setGlCode(glCode);
final Long chargeOffReasonId = mapping.getChargeOffReason().getId();
final String codeValue = mapping.getChargeOffReason().getLabel();
final String codeDescription = mapping.getChargeOffReason().getDescription();
final Integer orderPosition = mapping.getChargeOffReason().getPosition();
final boolean isActive = mapping.getChargeOffReason().isActive();
final boolean isMandatory = mapping.getChargeOffReason().isMandatory();
final CodeValueData chargeOffReasonsCodeValue = CodeValueData.builder().id(chargeOffReasonId).name(codeValue)
.description(codeDescription).position(orderPosition).active(isActive).mandatory(isMandatory).build();
final CodeValueData chargeOffReasonsCodeValue = codeValueMapper.map(mapping.getChargeOffReason());
final ChargeOffReasonToGLAccountMapper chargeOffReasonToGLAccountMapper = new ChargeOffReasonToGLAccountMapper()
.setChargeOffReasonCodeValue(chargeOffReasonsCodeValue).setExpenseAccount(chargeOffExpenseAccount);
@@ -299,6 +293,35 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
return chargeOffReasonToGLAccountMappers;
}
private List<ClassificationToGLAccountData> fetchClassificationMappings(final PortfolioProductType portfolioProductType,
final Long loanProductId, LoanProductAccountingParams classificationParameter) {
final List<ProductToGLAccountMapping> mappings = classificationParameter
.equals(LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS)
? productToGLAccountMappingRepository.findAllCapitalizedIncomeClassificationsMappings(loanProductId,
portfolioProductType.getValue())
: productToGLAccountMappingRepository.findAllBuyDownFeeClassificationsMappings(loanProductId,
portfolioProductType.getValue());
productToGLAccountMappingRepository.findAllChargeOffReasonsMappings(loanProductId, portfolioProductType.getValue());
List<ClassificationToGLAccountData> classificationToGLAccountMappers = mappings.isEmpty() ? null : new ArrayList<>();
for (final ProductToGLAccountMapping mapping : mappings) {
final Long glAccountId = mapping.getGlAccount().getId();
final String glAccountName = mapping.getGlAccount().getName();
final String glCode = mapping.getGlAccount().getGlCode();
final GLAccountData glAccountData = new GLAccountData().setId(glAccountId).setName(glAccountName).setGlCode(glCode);
final CodeValueData classificationCodeValue = classificationParameter
.equals(LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS)
? codeValueMapper.map(mapping.getCapitalizedIncomeClassification())
: codeValueMapper.map(mapping.getBuydownFeeClassification());
final ClassificationToGLAccountData classificationToGLAccountMapper = new ClassificationToGLAccountData()
.setClassificationCodeValue(classificationCodeValue).setIncomeAccount(glAccountData);
classificationToGLAccountMappers.add(classificationToGLAccountMapper);
}
return classificationToGLAccountMappers;
}
@Override
public Map<String, Object> fetchAccountMappingDetailsForShareProduct(Long productId, Integer accountingType) {
@@ -344,6 +367,12 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
return fetchChargeOffReasonMappings(PortfolioProductType.LOAN, loanProductId);
}
@Override
public List<ClassificationToGLAccountData> fetchClassificationMappingsForLoanProduct(Long loanProductId,
LoanProductAccountingParams classificationParameter) {
return fetchClassificationMappings(PortfolioProductType.LOAN, loanProductId, classificationParameter);
}
private Map<String, Object> setAccrualPeriodicSavingsProductToGLAccountMaps(final List<ProductToGLAccountMapping> mappings) {
final Map<String, Object> accountMappingDetails = new LinkedHashMap<>(8);