FINERACT-2113: Advanced Charge-off Expense Accounting - Add new "Advanced Accounting Rule"

This commit is contained in:
Andrii Kulminskyi
2024-11-21 17:18:06 +01:00
committed by Adam Saghy
parent 75fa740ff6
commit 4ccb33a95e
18 changed files with 621 additions and 14 deletions
@@ -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);
}
}
@@ -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")
@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);
@@ -61,4 +61,8 @@ public interface ProductToGLAccountMappingRepository
@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);
}
@@ -27,6 +27,7 @@ import java.util.HashSet;
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;
@@ -39,7 +40,11 @@ import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGL
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;
@@ -53,6 +58,7 @@ import org.springframework.stereotype.Component;
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;
@@ -60,6 +66,7 @@ public class ProductToGLAccountMappingHelper {
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) {
@@ -194,6 +201,27 @@ public class ProductToGLAccountMappingHelper {
}
}
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
@@ -356,6 +384,65 @@ public class ProductToGLAccountMappingHelper {
}
}
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
*
@@ -402,6 +489,24 @@ public class ProductToGLAccountMappingHelper {
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);
@@ -455,4 +560,38 @@ public class ProductToGLAccountMappingHelper {
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);
}
}
}
@@ -46,5 +46,4 @@ public interface ProductToGLAccountMappingReadPlatformService {
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForShareProduct(Long productId);
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForShareProduct(Long productId);
}
@@ -29,6 +29,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.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;
@@ -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);
}
/***
@@ -28,6 +28,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.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;
@@ -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);
}
/***
@@ -172,7 +172,10 @@ public final class AccountingConstants {
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;
@@ -33,6 +33,7 @@ import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingInvalidException;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingHelper;
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;
@@ -46,9 +47,9 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
public LoanProductToGLAccountMappingHelper(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);
}
/***
@@ -138,6 +139,16 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
saveChargesToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN, false);
}
public void saveChargeOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes) {
saveChargeOffReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN);
}
public void updateChargeOffReasonToExpenseAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes) {
updateChargeOffReasonToGLAccountMappings(command, element, productId, changes, PortfolioProductType.LOAN);
}
public void updateChargesToIncomeAccountMappings(final JsonCommand command, final JsonElement element, final Long productId,
final Map<String, Object> changes) {
// update both fee and penalty charges
@@ -387,5 +398,4 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
}
return gLAccountType;
}
}
@@ -246,6 +246,7 @@ final class LoanProductsApiResourceSwagger {
public Long incomeFromGoodwillCreditPenaltyAccountId;
public List<GetLoanProductsProductIdResponse.GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings;
public List<GetLoanProductsProductIdResponse.GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings;
public List<GetLoanProductsProductIdResponse.GetChargeOffReasonsToExpenseMappings> chargeOffReasonsToExpenseMappings;
public List<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
// Multi Disburse
@@ -1209,6 +1210,16 @@ final class LoanProductsApiResourceSwagger {
public Long fundSourceAccountId;
}
static final class GetChargeOffReasonsToExpenseMappings {
private GetChargeOffReasonsToExpenseMappings() {}
@Schema(example = "1")
public Long chargeOffReasonCodeValueId;
@Schema(example = "12")
public Long expenseGLAccountId;
}
static final class GetLoanFeeToIncomeAccountMappings {
private GetLoanFeeToIncomeAccountMappings() {}
@@ -1319,6 +1330,7 @@ final class LoanProductsApiResourceSwagger {
public GetLoanAccountingMappings accountingMappings;
public Set<GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings;
public Set<GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings;
public Set<GetChargeOffReasonsToExpenseMappings> chargeOffReasonsToExpenseMappings;
@Schema(example = "false")
public Boolean isRatesEnabled;
@Schema(example = "true")
@@ -1577,6 +1589,7 @@ final class LoanProductsApiResourceSwagger {
public Long incomeFromChargeOffPenaltyAccountId;
public List<GetLoanProductsProductIdResponse.GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings;
public List<GetLoanProductsProductIdResponse.GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings;
public List<GetLoanProductsProductIdResponse.GetChargeOffReasonsToExpenseMappings> chargeOffReasonsToExpenseMappings;
public List<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
@Schema(example = "false")
public Boolean enableAccrualActivityPosting;
@@ -151,6 +151,7 @@ public class LoanProductData implements Serializable {
private Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings;
private Collection<ChargeToGLAccountMapper> feeToIncomeAccountMappings;
private Collection<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings;
private List<ChargeToGLAccountMapper> chargeOffReasonsToExpenseMappings;
private final boolean enableAccrualActivityPosting;
// rates
@@ -853,6 +854,7 @@ public class LoanProductData implements Serializable {
this.paymentChannelToFundSourceMappings = null;
this.feeToIncomeAccountMappings = null;
this.penaltyToIncomeAccountMappings = null;
this.chargeOffReasonsToExpenseMappings = null;
this.valueConditionTypeOptions = null;
this.principalVariationsForBorrowerCycle = principalVariations;
this.interestRateVariationsForBorrowerCycle = interestRateVariations;
@@ -992,6 +994,7 @@ public class LoanProductData implements Serializable {
this.paymentChannelToFundSourceMappings = productData.paymentChannelToFundSourceMappings;
this.feeToIncomeAccountMappings = productData.feeToIncomeAccountMappings;
this.penaltyToIncomeAccountMappings = productData.penaltyToIncomeAccountMappings;
this.chargeOffReasonsToExpenseMappings = productData.chargeOffReasonsToExpenseMappings;
this.chargeOptions = chargeOptions;
this.penaltyOptions = penaltyOptions;
@@ -130,6 +130,7 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
// advanced accounting mappings
this.loanProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargeOffReasonToExpenseAccountMappings(command, element, loanProductId, null);
break;
case ACCRUAL_UPFRONT:
// Fall Through
@@ -208,6 +209,7 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
// advanced accounting mappings
this.loanProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, loanProductId, null);
this.loanProductToGLAccountMappingHelper.saveChargeOffReasonToExpenseAccountMappings(command, element, loanProductId, null);
break;
}
}
@@ -379,6 +381,8 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
accountingRuleType);
this.loanProductToGLAccountMappingHelper.updatePaymentChannelToFundSourceMappings(command, element, loanProductId, changes);
this.loanProductToGLAccountMappingHelper.updateChargesToIncomeAccountMappings(command, element, loanProductId, changes);
this.loanProductToGLAccountMappingHelper.updateChargeOffReasonToExpenseAccountMappings(command, element, loanProductId,
changes);
}
return changes;
}
@@ -160,7 +160,7 @@ public class LoanProductsApiResource {
@Operation(summary = "Create a Loan Product", description = "Depending of the Accounting Rule (accountingRule) selected, additional fields with details of the appropriate Ledger Account identifiers would need to be passed in.\n"
+ "\n" + "Refer MifosX Accounting Specs Draft for more details regarding the significance of the selected accounting rule\n\n"
+ "Mandatory Fields: name, shortName, currencyCode, digitsAfterDecimal, inMultiplesOf, principal, numberOfRepayments, repaymentEvery, repaymentFrequencyType, interestRatePerPeriod, interestRateFrequencyType, amortizationType, interestType, interestCalculationPeriodType, transactionProcessingStrategyCode, accountingRule, isInterestRecalculationEnabled, daysInYearType, daysInMonthType\n\n"
+ "Optional Fields: inArrearsTolerance, graceOnPrincipalPayment, graceOnInterestPayment, graceOnInterestCharged, graceOnArrearsAgeing, charges, paymentChannelToFundSourceMappings, feeToIncomeAccountMappings, penaltyToIncomeAccountMappings, includeInBorrowerCycle, useBorrowerCycle,principalVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, multiDisburseLoan,maxTrancheCount, outstandingLoanBalance,overdueDaysForNPA,holdGuaranteeFunds, principalThresholdForLastInstalment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, installmentAmountInMultiplesOf, allowAttributeOverrides, allowPartialPeriodInterestCalcualtion,dueDaysForRepaymentEvent,overDueDaysForRepaymentEvent,enableDownPayment,disbursedAmountPercentageDownPayment,enableAutoRepaymentForDownPayment,repaymentStartDateType\n\n"
+ "Optional Fields: inArrearsTolerance, graceOnPrincipalPayment, graceOnInterestPayment, graceOnInterestCharged, graceOnArrearsAgeing, charges, paymentChannelToFundSourceMappings, feeToIncomeAccountMappings, penaltyToIncomeAccountMappings, chargeOffReasonsToExpenseMappings, includeInBorrowerCycle, useBorrowerCycle,principalVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, multiDisburseLoan,maxTrancheCount, outstandingLoanBalance,overdueDaysForNPA,holdGuaranteeFunds, principalThresholdForLastInstalment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, installmentAmountInMultiplesOf, allowAttributeOverrides, allowPartialPeriodInterestCalcualtion,dueDaysForRepaymentEvent,overDueDaysForRepaymentEvent,enableDownPayment,disbursedAmountPercentageDownPayment,enableAutoRepaymentForDownPayment,repaymentStartDateType\n\n"
+ "Additional Mandatory Fields for Cash(2) based accounting: fundSourceAccountId, loanPortfolioAccountId, interestOnLoanAccountId, incomeFromFeeAccountId, incomeFromPenaltyAccountId, writeOffAccountId, transfersInSuspenseAccountId, overpaymentLiabilityAccountId\n\n"
+ "Additional Mandatory Fields for periodic (3) and upfront (4)accrual accounting: fundSourceAccountId, loanPortfolioAccountId, interestOnLoanAccountId, incomeFromFeeAccountId, incomeFromPenaltyAccountId, writeOffAccountId, receivableInterestAccountId, receivableFeeAccountId, receivablePenaltyAccountId, transfersInSuspenseAccountId, overpaymentLiabilityAccountId\n\n"
+ "Additional Mandatory Fields if interest recalculation is enabled(true): interestRecalculationCompoundingMethod, rescheduleStrategyMethod, recalculationRestFrequencyType\n\n"
@@ -336,6 +336,7 @@ public class LoanProductsApiResource {
Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings;
Collection<ChargeToGLAccountMapper> feeToGLAccountMappings;
Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings;
List<ChargeToGLAccountMapper> chargeOffReasonsToExpenseMappings;
if (loanProduct.hasAccountingEnabled()) {
accountingMappings = this.accountMappingReadPlatformService.fetchAccountMappingDetailsForLoanProduct(productId,
loanProduct.getAccountingRule().getId().intValue());
@@ -27,6 +27,7 @@ import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -36,6 +37,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams;
import org.apache.fineract.accounting.common.AccountingValidations;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
@@ -141,7 +143,9 @@ public final class LoanProductDataValidator {
LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_INTEREST.getValue(),
LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_FEES.getValue(),
LoanProductAccountingParams.INCOME_FROM_GOODWILL_CREDIT_PENALTY.getValue(),
LoanProductConstants.USE_BORROWER_CYCLE_PARAMETER_NAME,
LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue(),
LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(),
LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(), LoanProductConstants.USE_BORROWER_CYCLE_PARAMETER_NAME,
LoanProductConstants.PRINCIPAL_VARIATIONS_FOR_BORROWER_CYCLE_PARAMETER_NAME,
LoanProductConstants.INTEREST_RATE_VARIATIONS_FOR_BORROWER_CYCLE_PARAMETER_NAME,
LoanProductConstants.NUMBER_OF_REPAYMENT_VARIATIONS_FOR_BORROWER_CYCLE_PARAMETER_NAME, LoanProductConstants.SHORT_NAME,
@@ -195,6 +199,7 @@ public final class LoanProductDataValidator {
private final LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory;
private final AdvancedPaymentAllocationsJsonParser advancedPaymentAllocationsJsonParser;
private final AdvancedPaymentAllocationsValidator advancedPaymentAllocationsValidator;
private final ProductToGLAccountMappingHelper productToGLAccountMappingHelper;
public void validateForCreate(final JsonCommand command) {
String json = command.json();
@@ -718,6 +723,7 @@ public final class LoanProductDataValidator {
validatePaymentChannelFundSourceMappings(baseDataValidator, element);
validateChargeToIncomeAccountMappings(baseDataValidator, element);
validateChargeOffToExpenseMappings(baseDataValidator, element);
}
@@ -1791,6 +1797,7 @@ public final class LoanProductDataValidator {
validatePaymentChannelFundSourceMappings(baseDataValidator, element);
validateChargeToIncomeAccountMappings(baseDataValidator, element);
validateChargeOffToExpenseMappings(baseDataValidator, element);
validateMinMaxConstraints(element, baseDataValidator, loanProduct);
@@ -1964,6 +1971,60 @@ public final class LoanProductDataValidator {
}
}
private void validateChargeOffToExpenseMappings(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
String parameterName = LoanProductAccountingParams.CHARGE_OFF_REASONS_TO_EXPENSE.getValue();
if (this.fromApiJsonHelper.parameterExists(parameterName, element)) {
final JsonArray chargeOffToExpenseMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(parameterName, element);
if (chargeOffToExpenseMappingArray != null && chargeOffToExpenseMappingArray.size() > 0) {
Map<Long, Set<Long>> chargeOffReasonToAccounts = new HashMap<>();
List<JsonObject> processedMappings = new ArrayList<>(); // Collect processed mappings for the new method
int i = 0;
do {
final JsonObject jsonObject = chargeOffToExpenseMappingArray.get(i).getAsJsonObject();
final Long expenseGlAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue(), jsonObject);
final Long chargeOffReasonCodeValueId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue(), jsonObject);
// 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();
// Handle duplicate charge-off reason and GL Account validation
chargeOffReasonToAccounts.putIfAbsent(chargeOffReasonCodeValueId, new HashSet<>());
Set<Long> associatedAccounts = chargeOffReasonToAccounts.get(chargeOffReasonCodeValueId);
if (associatedAccounts.contains(expenseGlAccountId)) {
baseDataValidator.reset().parameter(parameterName + OPENING_SQUARE_BRACKET + i + CLOSING_SQUARE_BRACKET)
.failWithCode("duplicate.chargeOffReason.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");
}
// Collect mapping for additional validations
processedMappings.add(jsonObject);
i++;
} while (i < chargeOffToExpenseMappingArray.size());
// Call the new validation method for additional checks
productToGLAccountMappingHelper.validateChargeOffMappingsInDatabase(processedMappings);
}
}
}
public void validateMinMaxConstraints(final JsonElement element, final DataValidatorBuilder baseDataValidator,
final LoanProduct loanProduct) {
validatePrincipalMinMaxConstraint(element, loanProduct, baseDataValidator);
@@ -171,4 +171,5 @@
<include file="parts/0150_transaction_summary_with_asset_owner_report_interest_waiver_interest_refund_added.xml" relativeToChangelogFile="true" />
<include file="parts/0151_interest_refund_business_events.xml" relativeToChangelogFile="true" />
<include file="parts/0152_update_password_validation_policy.xml" relativeToChangelogFile="true" />
<include file="parts/0153_add_charge_off_reason_id_to_acc_product_mapping.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet id="1" author="fineract">
<addColumn tableName="acc_product_mapping">
<column name="charge_off_reason_id" type="INT" defaultValueNumeric="NULL">
<constraints nullable="true"/>
</column>
</addColumn>
<addForeignKeyConstraint
baseTableName="acc_product_mapping"
baseColumnNames="charge_off_reason_id"
referencedTableName="m_code_value"
referencedColumnNames="id"
constraintName="fk_acc_product_mapping_charge_off_reason"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,272 @@
/**
* 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.integrationtests;
import static org.apache.fineract.integrationtests.common.funds.FundsResourceHandler.createFund;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import org.apache.fineract.client.models.AllowAttributeOverrides;
import org.apache.fineract.client.models.ChargeData;
import org.apache.fineract.client.models.ChargeToGLAccountMapper;
import org.apache.fineract.client.models.GetChargeOffReasonsToExpenseMappings;
import org.apache.fineract.client.models.GetLoanFeeToIncomeAccountMappings;
import org.apache.fineract.client.models.GetLoanPaymentChannelToFundSourceMappings;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PutLoanProductsProductIdRequest;
import org.apache.fineract.client.util.CallFailedRuntimeException;
import org.apache.fineract.integrationtests.common.BusinessStepHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper;
import org.apache.fineract.integrationtests.common.system.CodeHelper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(LoanTestLifecycleExtension.class)
public class LoanProductChargeOffReasonMappingsTest extends BaseLoanIntegrationTest {
private static final String CODE_VALUE_NAME = "ChargeOffReasons";
private static ResponseSpecification responseSpec;
private static RequestSpecification requestSpec;
private static LoanTransactionHelper loanTransactionHelper;
@BeforeAll
public static void setup() {
Utils.initializeRESTAssured();
requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
requestSpec.header("Fineract-Platform-TenantId", "default");
responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build();
loanTransactionHelper = new LoanTransactionHelper(requestSpec, responseSpec);
BusinessStepHelper businessStepHelper = new BusinessStepHelper();
// setup COB Business Steps to prevent test failing due other integration test configurations
businessStepHelper.updateSteps("LOAN_CLOSE_OF_BUSINESS", "APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION",
"CHECK_LOAN_REPAYMENT_DUE", "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES",
"EXTERNAL_ASSET_OWNER_TRANSFER", "ACCRUAL_ACTIVITY_POSTING");
}
@Test
public void testCreateLoanProductWithValidChargeOffReason() {
final String creationBusinessDay = "15 January 2023";
runAt(creationBusinessDay, () -> {
Integer chargeOffReasons = createChargeOffReason();
Long localLoanProductId = loanTransactionHelper.createLoanProduct(loanProductsRequest(Long.valueOf(chargeOffReasons), 15L))
.getResourceId();
Assertions.assertNotNull(localLoanProductId);
});
}
@Test
public void testUpdateLoanProductWithValidChargeOffReason() {
final String creationBusinessDay = "15 January 2023";
runAt(creationBusinessDay, () -> {
Integer chargeOffReasons = createChargeOffReason();
List<GetChargeOffReasonsToExpenseMappings> chargeOffReasonsToExpenseMappings = new ArrayList<>();
GetChargeOffReasonsToExpenseMappings getChargeOffReasonsToExpenseMappings = new GetChargeOffReasonsToExpenseMappings();
getChargeOffReasonsToExpenseMappings.setChargeOffReasonCodeValueId(Long.valueOf(chargeOffReasons));
getChargeOffReasonsToExpenseMappings.setExpenseGLAccountId(15L);
chargeOffReasonsToExpenseMappings.add(getChargeOffReasonsToExpenseMappings);
Long localLoanProductId = loanTransactionHelper.updateLoanProduct(1L,
new PutLoanProductsProductIdRequest().locale("en").chargeOffReasonsToExpenseMappings(chargeOffReasonsToExpenseMappings))
.getResourceId();
Assertions.assertNotNull(localLoanProductId);
});
}
@Test
public void testCreateLoanProductWithInvalidGLAccount() {
final String creationBusinessDay = "15 January 2023";
runAt(creationBusinessDay, () -> {
try {
Integer chargeOffReasons = createChargeOffReason();
loanTransactionHelper.createLoanProduct(loanProductsRequest(Long.valueOf(chargeOffReasons), 9999L));
} catch (CallFailedRuntimeException e) {
Assertions.assertTrue(e.getMessage().contains("validation.msg.glaccount.not.found"));
}
});
}
@Test
public void testCreateLoanProductWithInvalidChargeOffReason() {
final String creationBusinessDay = "15 January 2023";
runAt(creationBusinessDay, () -> {
try {
loanTransactionHelper.createLoanProduct(loanProductsRequest(1L, 12L));
} catch (CallFailedRuntimeException e) {
Assertions.assertTrue(e.getMessage().contains("validation.msg.chargeoffreason.invalid"));
}
});
}
private PostLoanProductsRequest loanProductsRequest(Long chargeOffReasonId, Long glAccountId) {
String name = Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_", 6);
String shortName = Utils.uniqueRandomStringGenerator("", 4);
List<Integer> principalVariationsForBorrowerCycle = new ArrayList<>();
List<Integer> numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>();
List<Integer> interestRateVariationsForBorrowerCycle = new ArrayList<>();
List<ChargeData> charges = new ArrayList<>();
List<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings = new ArrayList<>();
List<GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings = new ArrayList<>();
List<GetChargeOffReasonsToExpenseMappings> chargeOffReasonsToExpenseMappings = new ArrayList<>();
GetChargeOffReasonsToExpenseMappings getChargeOffReasonsToExpenseMappings = new GetChargeOffReasonsToExpenseMappings();
getChargeOffReasonsToExpenseMappings.setChargeOffReasonCodeValueId(chargeOffReasonId);
getChargeOffReasonsToExpenseMappings.setExpenseGLAccountId(glAccountId);
chargeOffReasonsToExpenseMappings.add(getChargeOffReasonsToExpenseMappings);
List<GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings = new ArrayList<>();
GetLoanPaymentChannelToFundSourceMappings loanPaymentChannelToFundSourceMappings = new GetLoanPaymentChannelToFundSourceMappings();
loanPaymentChannelToFundSourceMappings.fundSourceAccountId(fundSource.getAccountID().longValue());
loanPaymentChannelToFundSourceMappings.paymentTypeId(1L);
paymentChannelToFundSourceMappings.add(loanPaymentChannelToFundSourceMappings);
final Integer fundId = createFund(requestSpec, responseSpec);
Assertions.assertNotNull(fundId);
final Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec);
Assertions.assertNotNull(delinquencyBucketId);
return new PostLoanProductsRequest()//
.name(name)//
.enableAccrualActivityPosting(true)//
.shortName(shortName)//
.description(
"LP1 with 12% DECLINING BALANCE interest, interest period: Daily, Interest recalculation-Daily, Compounding:none")//
.fundId(fundId.longValue())//
.startDate(null)//
.closeDate(null)//
.includeInBorrowerCycle(false)//
.currencyCode("EUR")//
.digitsAfterDecimal(2)//
.inMultiplesOf(1)//
.installmentAmountInMultiplesOf(1)//
.useBorrowerCycle(false)//
.minPrincipal(100.0)//
.principal(1000.0)//
.maxPrincipal(10000.0)//
.minNumberOfRepayments(1)//
.numberOfRepayments(1)//
.maxNumberOfRepayments(30)//
.isLinkedToFloatingInterestRates(false)//
.minInterestRatePerPeriod(0.0)//
.interestRatePerPeriod(12.0)//
.maxInterestRatePerPeriod(30.0)//
.interestRateFrequencyType(3)//
.repaymentEvery(30)//
.repaymentFrequencyType(0L)//
.principalVariationsForBorrowerCycle(principalVariationsForBorrowerCycle)//
.numberOfRepaymentVariationsForBorrowerCycle(numberOfRepaymentVariationsForBorrowerCycle)//
.interestRateVariationsForBorrowerCycle(interestRateVariationsForBorrowerCycle)//
.amortizationType(1)//
.interestType(0)//
.isEqualAmortization(false)//
.interestCalculationPeriodType(0)//
.transactionProcessingStrategyCode("mifos-standard-strategy")//
.daysInYearType(1)//
.daysInMonthType(1)//
.canDefineInstallmentAmount(true)//
.graceOnArrearsAgeing(3)//
.overdueDaysForNPA(179)//
.accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
.principalThresholdForLastInstallment(50)//
.allowVariableInstallments(false)//
.canUseForTopup(false)//
.holdGuaranteeFunds(false)//
.multiDisburseLoan(false)//
.allowAttributeOverrides(new AllowAttributeOverrides()//
.amortizationType(true)//
.interestType(true)//
.transactionProcessingStrategyCode(true)//
.interestCalculationPeriodType(true)//
.inArrearsTolerance(true)//
.repaymentEvery(true)//
.graceOnPrincipalAndInterestPayment(true)//
.graceOnArrearsAgeing(true))
.outstandingLoanBalance(10000.0)//
.charges(charges)//
.accountingRule(3)//
.fundSourceAccountId(suspenseAccount.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())//
.chargeOffExpenseAccountId(chargeOffExpenseAccount.getAccountID().longValue())//
.chargeOffFraudExpenseAccountId(chargeOffFraudExpenseAccount.getAccountID().longValue())//
.incomeFromChargeOffPenaltyAccountId(penaltyChargeOffAccount.getAccountID().longValue())//
.dateFormat("dd MMMM yyyy")//
.locale("en")//
.disallowExpectedDisbursements(false)//
.allowApprovedDisbursedAmountsOverApplied(false)//
.delinquencyBucketId(delinquencyBucketId.longValue())//
.paymentChannelToFundSourceMappings(paymentChannelToFundSourceMappings)//
.penaltyToIncomeAccountMappings(penaltyToIncomeAccountMappings)//
.chargeOffReasonsToExpenseMappings(chargeOffReasonsToExpenseMappings).feeToIncomeAccountMappings(feeToIncomeAccountMappings)//
.isInterestRecalculationEnabled(true)//
.preClosureInterestCalculationStrategy(1)//
.rescheduleStrategyMethod(3)//
.interestRecalculationCompoundingMethod(0)//
.recalculationRestFrequencyType(2)//
.recalculationRestFrequencyInterval(1)//
.allowPartialPeriodInterestCalcualtion(false);//
}
private Integer createChargeOffReason() {
Integer chargeOffReasonId;
HashMap<String, Object> codes = CodeHelper.getCodeByName(requestSpec, responseSpec, CODE_VALUE_NAME);
if (codes.isEmpty()) {
CodeHelper.createCode(requestSpec, responseSpec, CODE_VALUE_NAME, "");
}
codes = CodeHelper.getCodeByName(requestSpec, responseSpec, CODE_VALUE_NAME);
Integer codeId = (Integer) codes.get("id");
HashMap<String, Object> codeValues = CodeHelper.getOrCreateCodeValueByCodeIdAndCodeName(requestSpec, responseSpec, codeId,
CODE_VALUE_NAME, 1);
chargeOffReasonId = (Integer) codeValues.get("id");
return chargeOffReasonId;
}
}
@@ -107,6 +107,7 @@ public class LoanProductTestBuilder {
private List<Map<String, Long>> feeToIncomeAccountMappings = null;
private List<Map<String, Long>> penaltyToIncomeAccountMappings = null;
private List<Map<String, Long>> chargeOffReasonsToExpenseMappings = null;
private Account feeAndPenaltyAssetAccount;
private Boolean multiDisburseLoan = false;
@@ -303,6 +304,10 @@ public class LoanProductTestBuilder {
map.put("penaltyToIncomeAccountMappings", this.penaltyToIncomeAccountMappings);
}
if (this.chargeOffReasonsToExpenseMappings != null) {
map.put("chargeOffReasonsToExpenseMappings", this.chargeOffReasonsToExpenseMappings);
}
if (this.dueDaysForRepaymentEvent != null) {
map.put("dueDaysForRepaymentEvent", this.dueDaysForRepaymentEvent);
}
@@ -796,6 +801,17 @@ public class LoanProductTestBuilder {
return this;
}
public LoanProductTestBuilder withChargeOffReasonsToExpenseMappings(final Long reasonId, final Long accountId) {
if (this.chargeOffReasonsToExpenseMappings == null) {
this.chargeOffReasonsToExpenseMappings = new ArrayList<>();
}
Map<String, Long> newMap = new HashMap<>();
newMap.put("chargeOffReasonCodeValueId", reasonId);
newMap.put("expenseGLAccountId", accountId);
this.chargeOffReasonsToExpenseMappings.add(newMap);
return this;
}
public String getTransactionProcessingStrategyCode() {
return transactionProcessingStrategyCode;
}
@@ -93,6 +93,35 @@ public final class CodeHelper {
return code;
}
public static HashMap<String, Object> getOrCreateCodeValueByCodeIdAndCodeName(final RequestSpecification requestSpec,
final ResponseSpecification responseSpec, final Integer codeId, final String codeName, final Integer position) {
ArrayList<HashMap<String, Object>> allCodeValues = CodeHelper.getAllCodeValuesByCodeId(requestSpec, responseSpec, codeId);
HashMap<String, Object> codesByName = filterCodesByName(allCodeValues, codeName);
if (codesByName.isEmpty()) {
CodeHelper.createCodeValue(requestSpec, responseSpec, codeId, codeName, position);
allCodeValues = CodeHelper.getAllCodeValuesByCodeId(requestSpec, responseSpec, codeId);
}
return filterCodesByName(allCodeValues, codeName);
}
private static HashMap<String, Object> filterCodesByName(ArrayList<HashMap<String, Object>> allCodeValues, String codeName) {
final HashMap<String, Object> codes = new HashMap<>();
for (HashMap<String, Object> map : allCodeValues) {
String name = (String) map.get("name");
if (name.equals(codeName)) {
codes.put("id", map.get("id"));
codes.put("name", map.get("name"));
break;
}
}
return codes;
}
public static HashMap<String, Object> retrieveOrCreateCodeValue(Integer codeId, final RequestSpecification requestSpec,
final ResponseSpecification responseSpec) {
Integer codeValueId = null;
@@ -119,6 +148,13 @@ public final class CodeHelper {
}
public static ArrayList<HashMap<String, Object>> getAllCodeValuesByCodeId(final RequestSpecification requestSpec,
final ResponseSpecification responseSpec, final Integer codeId) {
return Utils.performServerGet(requestSpec, responseSpec,
CODE_VALUE_URL.replace("[codeId]", codeId.toString()) + "?" + Utils.TENANT_IDENTIFIER, "");
}
public static Object getSystemDefinedCodes(final RequestSpecification requestSpec, final ResponseSpecification responseSpec) {
final String getResponse = given().spec(requestSpec).expect().spec(responseSpec).when()