Skip to content

Commit

Permalink
FINERACT-2113: Advanced Charge-off Expense Accounting - Add new "Adva…
Browse files Browse the repository at this point in the history
…nced Accounting Rule"
  • Loading branch information
kulminsky authored and adamsaghy committed Nov 21, 2024
1 parent 75fa740 commit 4ccb33a
Show file tree
Hide file tree
Showing 18 changed files with 621 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ public class ProductToGLAccountMapping extends AbstractPersistableCustom<Long> {
@Column(name = "financial_account_type", nullable = true)
private int financialAccountType;

@Column(name = "charge_off_reason_id", nullable = true)
private Long chargeOffReasonId;

public static ProductToGLAccountMapping createNew(final GLAccount glAccount, final Long productId, final int productType,
final int financialAccountType) {
final int financialAccountType, final Long chargeOffReasonId) {

return new ProductToGLAccountMapping().setGlAccount(glAccount).setProductId(productId).setProductType(productType)
.setFinancialAccountType(financialAccountType);
.setFinancialAccountType(financialAccountType).setChargeOffReasonId(chargeOffReasonId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ ProductToGLAccountMapping findProductIdAndProductTypeAndFinancialAccountTypeAndC
@Param("productType") int productType, @Param("financialAccountType") int financialAccountType,
@Param("chargeId") Long ChargeId);

@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.financialAccountType=:financialAccountType and mapping.paymentType is NULL and mapping.charge is NULL")
@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.financialAccountType=:financialAccountType and mapping.paymentType is NULL and mapping.charge is NULL and mapping.chargeOffReasonId is NULL")
ProductToGLAccountMapping findCoreProductToFinAccountMapping(@Param("productId") Long productId, @Param("productType") int productType,
@Param("financialAccountType") int financialAccountType);

Expand All @@ -61,4 +61,8 @@ List<ProductToGLAccountMapping> findAllPenaltyToIncomeAccountMappings(@Param("pr
@Param("productType") int productType);

List<ProductToGLAccountMapping> findByProductIdAndProductType(Long productId, int productType);

@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId and mapping.productType =:productType and mapping.chargeOffReasonId is not NULL")
List<ProductToGLAccountMapping> findAllChargesOffReasonsMappings(@Param("productId") Long productId,
@Param("productType") int productType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
Expand All @@ -39,7 +40,11 @@
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingInvalidException;
import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.charge.domain.Charge;
Expand All @@ -53,13 +58,15 @@
public class ProductToGLAccountMappingHelper {

protected static final List<GLAccountType> ASSET_LIABILITY_TYPES = List.of(GLAccountType.ASSET, GLAccountType.LIABILITY);
private static final Integer GL_ACCOUNT_EXPENSE_TYPE = 5;

protected final GLAccountRepository accountRepository;
protected final ProductToGLAccountMappingRepository accountMappingRepository;
protected final FromJsonHelper fromApiJsonHelper;
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
protected final GLAccountRepositoryWrapper accountRepositoryWrapper;
private final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper;
private final CodeValueRepository codeValueRepository;

public void saveProductToAccountMapping(final JsonElement element, final String paramName, final Long productId,
final int placeHolderTypeId, final GLAccountType expectedAccountType, final PortfolioProductType portfolioProductType) {
Expand Down Expand Up @@ -194,6 +201,27 @@ public void saveChargesToGLAccountMappings(final JsonCommand command, final Json
}
}

public void saveChargeOffReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes, final PortfolioProductType portfolioProductType) {

final String arrayName = LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue();
final JsonArray chargeOffReasonToExpenseAccountMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(arrayName, element);

if (chargeOffReasonToExpenseAccountMappingArray != null) {
if (changes != null) {
changes.put(arrayName, command.jsonFragment(arrayName));
}

for (int i = 0; i < chargeOffReasonToExpenseAccountMappingArray.size(); i++) {
final JsonObject jsonObject = chargeOffReasonToExpenseAccountMappingArray.get(i).getAsJsonObject();
final Long reasonId = jsonObject.get(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue()).getAsLong();
final Long expenseAccountId = jsonObject.get(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()).getAsLong();

saveChargeOffReasonToExpenseMapping(productId, reasonId, expenseAccountId, portfolioProductType);
}
}
}

/**
* @param command
* @param element
Expand Down Expand Up @@ -356,6 +384,65 @@ public void updatePaymentChannelToFundSourceMappings(final JsonCommand command,
}
}

public void updateChargeOffReasonToGLAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes, final PortfolioProductType portfolioProductType) {

final List<ProductToGLAccountMapping> existingChargeOffReasonToGLAccountMappings = this.accountMappingRepository
.findAllChargesOffReasonsMappings(productId, portfolioProductType.getValue());
final JsonArray chargeOffReasonToGLAccountMappingArray = this.fromApiJsonHelper
.extractJsonArrayNamed(LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(), element);

final Map<Long, Long> inputChargeOffReasonToGLAccountMap = new HashMap<>();

final Set<Long> existingChargeOffReasons = new HashSet<>();
if (chargeOffReasonToGLAccountMappingArray != null) {
if (changes != null) {
changes.put(LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(),
command.jsonFragment(LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue()));
}

for (int i = 0; i < chargeOffReasonToGLAccountMappingArray.size(); i++) {
final JsonObject jsonObject = chargeOffReasonToGLAccountMappingArray.get(i).getAsJsonObject();
final Long expenseGlAccountId = jsonObject.get(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()).getAsLong();
final Long chargeOffReasonCodeValueId = jsonObject
.get(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue()).getAsLong();
inputChargeOffReasonToGLAccountMap.put(chargeOffReasonCodeValueId, expenseGlAccountId);
}

// If input map is empty, delete all existing mappings
if (inputChargeOffReasonToGLAccountMap.isEmpty()) {
this.accountMappingRepository.deleteAllInBatch(existingChargeOffReasonToGLAccountMappings);
} else {
for (final ProductToGLAccountMapping existingChargeOffReasonToGLAccountMapping : existingChargeOffReasonToGLAccountMappings) {
final Long currentChargeOffReasonId = existingChargeOffReasonToGLAccountMapping.getChargeOffReasonId();
if (currentChargeOffReasonId != null) {
existingChargeOffReasons.add(currentChargeOffReasonId);
// update existing mappings (if required)
if (inputChargeOffReasonToGLAccountMap.containsKey(currentChargeOffReasonId)) {
final Long newGLAccountId = inputChargeOffReasonToGLAccountMap.get(currentChargeOffReasonId);
if (!newGLAccountId.equals(existingChargeOffReasonToGLAccountMapping.getGlAccount().getId())) {
final Optional<GLAccount> glAccount = accountRepository.findById(newGLAccountId);
if (glAccount.isPresent()) {
existingChargeOffReasonToGLAccountMapping.setGlAccount(glAccount.get());
this.accountMappingRepository.saveAndFlush(existingChargeOffReasonToGLAccountMapping);
}
}
} // deleted payment type
else {
this.accountMappingRepository.delete(existingChargeOffReasonToGLAccountMapping);
}
}
}

// only the newly added
for (Map.Entry<Long, Long> entry : inputChargeOffReasonToGLAccountMap.entrySet().stream()
.filter(e -> !existingChargeOffReasons.contains(e.getKey())).toList()) {
saveChargeOffReasonToExpenseMapping(productId, entry.getKey(), entry.getValue(), portfolioProductType);
}
}
}
}

/**
* @param productId
*
Expand Down Expand Up @@ -402,6 +489,24 @@ private void saveChargeToFundSourceMapping(final Long productId, final Long char
this.accountMappingRepository.saveAndFlush(accountMapping);
}

private void saveChargeOffReasonToExpenseMapping(final Long productId, final Long reasonId, final Long expenseAccountId,
final PortfolioProductType portfolioProductType) {

final Optional<GLAccount> glAccount = accountRepository.findById(expenseAccountId);

final boolean reasonMappingExists = this.accountMappingRepository
.findAllChargesOffReasonsMappings(productId, portfolioProductType.getValue()).stream()
.anyMatch(mapping -> mapping.getChargeOffReasonId().equals(reasonId));

if (glAccount.isPresent() && !reasonMappingExists) {
final ProductToGLAccountMapping accountMapping = new ProductToGLAccountMapping().setGlAccount(glAccount.get())
.setProductId(productId).setProductType(portfolioProductType.getValue())
.setFinancialAccountType(CashAccountsForLoan.CHARGE_OFF_EXPENSE.getValue()).setChargeOffReasonId(reasonId);

this.accountMappingRepository.saveAndFlush(accountMapping);
}
}

private List<GLAccountType> getAllowedAccountTypesForFeeMapping() {
List<GLAccountType> allowedAccountTypes = new ArrayList<>();
allowedAccountTypes.add(GLAccountType.INCOME);
Expand Down Expand Up @@ -455,4 +560,38 @@ public void deleteProductToGLAccountMapping(final Long loanProductId, final Port
this.accountMappingRepository.deleteAllInBatch(productToGLAccountMappings);
}
}

public void validateChargeOffMappingsInDatabase(final List<JsonObject> mappings) {
final List<ApiParameterError> validationErrors = new ArrayList<>();

for (JsonObject jsonObject : mappings) {
final Long expenseGlAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), jsonObject);
final Long chargeOffReasonCodeValueId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(), jsonObject);

// Validation: chargeOffReasonCodeValueId must exist in the database
CodeValue codeValue = this.codeValueRepository.findByCodeNameAndId("ChargeOffReasons", chargeOffReasonCodeValueId);
if (codeValue == null) {
validationErrors.add(ApiParameterError.parameterError("validation.msg.chargeoffreason.invalid",
"Charge-off reason with ID " + chargeOffReasonCodeValueId + " does not exist",
LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue()));
}

// Validation: expenseGLAccountId must exist as a valid Expense GL account
final Optional<GLAccount> glAccount = accountRepository.findById(expenseGlAccountId);

if (glAccount.isEmpty() || !glAccount.get().getType().equals(GL_ACCOUNT_EXPENSE_TYPE)) {
validationErrors.add(ApiParameterError.parameterError("validation.msg.glaccount.not.found",
"GL Account with ID " + expenseGlAccountId + " does not exist or is not an Expense GL account",
LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()));

}
}

// Throw all collected validation errors, if any
if (!validationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(validationErrors);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,4 @@ public interface ProductToGLAccountMappingReadPlatformService {
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForShareProduct(Long productId);

List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForShareProduct(Long productId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.PortfolioProductType;
Expand All @@ -42,9 +43,9 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
public SavingsProductToGLAccountMappingHelper(final GLAccountRepository glAccountRepository,
final ProductToGLAccountMappingRepository glAccountMappingRepository, final FromJsonHelper fromApiJsonHelper,
final ChargeRepositoryWrapper chargeRepositoryWrapper, final GLAccountRepositoryWrapper accountRepositoryWrapper,
final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper) {
final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper, final CodeValueRepository codeValueRepository) {
super(glAccountRepository, glAccountMappingRepository, fromApiJsonHelper, chargeRepositoryWrapper, accountRepositoryWrapper,
paymentTypeRepositoryWrapper);
paymentTypeRepositoryWrapper, codeValueRepository);
}

/***
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.PortfolioProductType;
Expand All @@ -41,9 +42,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
public ShareProductToGLAccountMappingHelper(final GLAccountRepository glAccountRepository,
final ProductToGLAccountMappingRepository glAccountMappingRepository, final FromJsonHelper fromApiJsonHelper,
final ChargeRepositoryWrapper chargeRepositoryWrapper, final GLAccountRepositoryWrapper accountRepositoryWrapper,
final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper) {
final PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper, final CodeValueRepository codeValueRepository) {
super(glAccountRepository, glAccountMappingRepository, fromApiJsonHelper, chargeRepositoryWrapper, accountRepositoryWrapper,
paymentTypeRepositoryWrapper);
paymentTypeRepositoryWrapper, codeValueRepository);
}

/***
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ public enum LoanProductAccountingParams {
INCOME_FROM_CHARGE_OFF_PENALTY("incomeFromChargeOffPenaltyAccountId"), //
INCOME_FROM_GOODWILL_CREDIT_INTEREST("incomeFromGoodwillCreditInterestAccountId"), //
INCOME_FROM_GOODWILL_CREDIT_FEES("incomeFromGoodwillCreditFeesAccountId"), //
INCOME_FROM_GOODWILL_CREDIT_PENALTY("incomeFromGoodwillCreditPenaltyAccountId"); //
INCOME_FROM_GOODWILL_CREDIT_PENALTY("incomeFromGoodwillCreditPenaltyAccountId"), //
CHARGE_OFF_REASONS_TO_EXPENSE("chargeOffReasonsToExpenseMappings"), //
EXPENSE_GL_ACCOUNT_ID("expenseGLAccountId"), //
CHARGE_OFF_REASON_CODE_VALUE_ID("chargeOffReasonCodeValueId"); //

private final String value;

Expand Down
Loading

0 comments on commit 4ccb33a

Please sign in to comment.