FINERACT-2330: Buy-Down fees Accounting for non merchant product

This commit is contained in:
Jose Alberto Hernandez
2025-08-15 22:54:36 +02:00
committed by Arnold Gálovics
parent 125c8b4db2
commit 6bbc4bd3f3
32 changed files with 233 additions and 65 deletions
@@ -47,4 +47,5 @@ public class LoanDTO {
private boolean markedAsFraud;
private Long chargeOffReasonCodeValue;
private boolean markedAsWrittenOff;
private boolean merchantBuyDownFee;
}
@@ -111,6 +111,7 @@ public class AccountingProcessorHelper {
final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled();
final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled();
final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled();
final boolean merchantBuyDownFee = accountingBridgeData.isMerchantBuyDownFee();
final List<AccountingBridgeLoanTransactionDTO> loanTransactionDTOs = accountingBridgeData.getNewLoanTransactions();
@@ -170,7 +171,7 @@ public class AccountingProcessorHelper {
return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled,
periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud,
chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff);
chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff, merchantBuyDownFee);
}
public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType,
@@ -435,8 +435,10 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
final BigDecimal amount = loanTransactionDTO.getAmount();
final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId();
final AccrualAccountsForLoan debitAccountType = loanDTO.isMerchantBuyDownFee() ? AccrualAccountsForLoan.BUY_DOWN_EXPENSE
: AccrualAccountsForLoan.FUND_SOURCE;
if (MathUtil.isGreaterThanZero(amount)) {
this.helper.createJournalEntriesForLoan(office, currencyCode, AccrualAccountsForLoan.BUY_DOWN_EXPENSE.getValue(),
this.helper.createJournalEntriesForLoan(office, currencyCode, debitAccountType.getValue(),
AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), loanProductId, paymentTypeId, loanId, transactionId,
transactionDate, amount);
}
@@ -41,6 +41,7 @@ import org.apache.fineract.accounting.producttoaccountmapping.service.SavingsPro
import org.apache.fineract.accounting.producttoaccountmapping.service.ShareProductToGLAccountMappingHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.loanproduct.LoanProductConstants;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -61,6 +62,14 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
final JsonElement element = this.fromApiJsonHelper.parse(command.json());
final Integer accountingRuleTypeId = this.fromApiJsonHelper.extractIntegerNamed("accountingRule", element, Locale.getDefault());
final AccountingRuleType accountingRuleType = AccountingRuleType.fromInt(accountingRuleTypeId);
boolean merchantBuyDownFee = true;
if (fromApiJsonHelper.parameterExists(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME, element)) {
final Boolean merchantBuyDownFeeParamValue = fromApiJsonHelper
.extractBooleanNamed(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME, element);
if (merchantBuyDownFeeParamValue != null) {
merchantBuyDownFee = merchantBuyDownFeeParamValue;
}
}
switch (accountingRuleType) {
case NONE:
@@ -207,9 +216,11 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
this.loanProductToGLAccountMappingHelper.saveLoanToExpenseAccountMapping(element,
LoanProductAccountingParams.CHARGE_OFF_FRAUD_EXPENSE.getValue(), loanProductId,
AccrualAccountsForLoan.CHARGE_OFF_FRAUD_EXPENSE.getValue());
this.loanProductToGLAccountMappingHelper.saveLoanToExpenseAccountMapping(element,
LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), loanProductId,
AccrualAccountsForLoan.BUY_DOWN_EXPENSE.getValue());
if (merchantBuyDownFee) {
this.loanProductToGLAccountMappingHelper.saveLoanToExpenseAccountMapping(element,
LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), loanProductId,
AccrualAccountsForLoan.BUY_DOWN_EXPENSE.getValue());
}
// liabilities
this.loanProductToGLAccountMappingHelper.saveLoanToLiabilityAccountMapping(element,
@@ -370,7 +381,7 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
@Transactional
public Map<String, Object> updateLoanProductToGLAccountMapping(final Long loanProductId, final JsonCommand command,
final boolean accountingRuleChanged, final AccountingRuleType accountingRuleType, final boolean enableIncomeCapitalization,
final boolean enableBuyDownFee) {
final boolean enableBuyDownFee, final boolean merchantBuyDownFee) {
/***
* Variable tracks all accounting mapping properties that have been updated
***/
@@ -390,7 +401,7 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
} /*** else examine and update individual changes ***/
else {
this.loanProductToGLAccountMappingHelper.handleChangesToLoanProductToGLAccountMappings(loanProductId, changes, element,
accountingRuleType, enableIncomeCapitalization, enableBuyDownFee);
accountingRuleType, enableIncomeCapitalization, enableBuyDownFee, merchantBuyDownFee);
this.loanProductToGLAccountMappingHelper.updatePaymentChannelToFundSourceMappings(command, element, loanProductId, changes);
this.loanProductToGLAccountMappingHelper.updateChargesToIncomeAccountMappings(command, element, loanProductId, changes);
this.loanProductToGLAccountMappingHelper.updateChargeOffReasonToExpenseAccountMappings(command, element, loanProductId,
@@ -554,7 +554,8 @@ public class LoanScheduleAssembler {
loanProduct.getLoanProductRelatedDetail().isEnableBuyDownFee(),
loanProduct.getLoanProductRelatedDetail().getBuyDownFeeCalculationType(),
loanProduct.getLoanProductRelatedDetail().getBuyDownFeeStrategy(),
loanProduct.getLoanProductRelatedDetail().getBuyDownFeeIncomeType());
loanProduct.getLoanProductRelatedDetail().getBuyDownFeeIncomeType(),
loanProduct.getLoanProductRelatedDetail().isMerchantBuyDownFee());
}
private CalendarInstance createCalendarForSameAsRepayment(final Integer repaymentEvery,
@@ -445,7 +445,7 @@ public class LoanAccrualsProcessingServiceImpl implements LoanAccrualsProcessing
loan.getOfficeId(), loan.getCurrencyCode(), loan.getSummary().getTotalInterestCharged(),
loan.isNoneOrCashOrUpfrontAccrualAccountingEnabledOnLoanProduct(),
loan.isUpfrontAccrualAccountingEnabledOnLoanProduct(), loan.isPeriodicAccrualAccountingEnabledOnLoanProduct(), false,
false, false, null, false, newTransactionDTOs);
false, false, null, false, newTransactionDTOs, loan.getLoanProductRelatedDetail().isMerchantBuyDownFee());
this.journalEntryWritePlatformService.createJournalEntriesForLoan(accountingBridgeData);
}
}
@@ -327,6 +327,8 @@ public class LoanProductAssembler {
.enumValueOfParameterNamed(LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanBuyDownFeeStrategy.class);
final LoanBuyDownFeeIncomeType buyDownFeeIncomeType = command
.enumValueOfParameterNamed(LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME, LoanBuyDownFeeIncomeType.class);
final boolean merchantBuyDownFee = command
.booleanPrimitiveValueOfParameterNamed(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME);
return new LoanProduct(fund, loanTransactionProcessingStrategy, loanProductPaymentAllocationRules, loanProductCreditAllocationRules,
name, shortName, description, currency, principal, minPrincipal, maxPrincipal, interestRatePerPeriod,
@@ -349,7 +351,8 @@ public class LoanProductAssembler {
repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, loanScheduleProcessingType, fixedLength,
enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate,
daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy,
capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType);
capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType,
merchantBuyDownFee);
}
@@ -359,6 +359,13 @@ public class LoanProductRelatedDetailUpdateUtil {
loanRepaymentScheduleDetail.setBuyDownFeeIncomeType(newValue);
}
if (command.isChangeInBooleanParameterNamed(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME,
loanRepaymentScheduleDetail.isMerchantBuyDownFee())) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME);
actualChanges.put(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME, newValue);
loanRepaymentScheduleDetail.setMerchantBuyDownFee(newValue);
}
return actualChanges;
}
@@ -847,9 +847,9 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
+ " l.enable_income_capitalization as enableIncomeCapitalization, "
+ " l.capitalized_income_calculation_type as capitalizedIncomeCalculationType, "
+ " l.capitalized_income_strategy as capitalizedIncomeStrategy, "
+ " l.capitalized_income_type as capitalizedIncomeType, " //
+ " l.enable_buy_down_fee as enableBuyDownFee, " + " l.buy_down_fee_calculation_type as buyDownFeeCalculationType, "
+ " l.buy_down_fee_strategy as buyDownFeeStrategy, " + " l.buy_down_fee_income_type as buyDownFeeIncomeType, "
+ " l.capitalized_income_type as capitalizedIncomeType, l.is_merchant_buy_down_fee as merchantBuyDownFee, " //
+ " l.enable_buy_down_fee as enableBuyDownFee, l.buy_down_fee_calculation_type as buyDownFeeCalculationType, "
+ " l.buy_down_fee_strategy as buyDownFeeStrategy, l.buy_down_fee_income_type as buyDownFeeIncomeType, "
+ " l.create_standing_instruction_at_disbursement as createStandingInstructionAtDisbursement, "
+ " lpvi.minimum_gap as minimuminstallmentgap, lpvi.maximum_gap as maximuminstallmentgap, "
+ " lp.can_use_for_topup as canUseForTopup, l.is_topup as isTopup, topup.closure_loan_id as closureLoanId, "
@@ -1246,6 +1246,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
rs.getString("buyDownFeeStrategy"));
final StringEnumOptionData buyDownFeeIncomeType = ApiFacingEnum.getStringEnumOptionData(LoanBuyDownFeeIncomeType.class,
rs.getString("buyDownFeeIncomeType"));
final boolean merchantBuyDownFee = rs.getBoolean("merchantBuyDownFee");
return LoanAccountData.basicLoanDetails(id, accountNo, status, externalId, clientId, clientAccountNo, clientName,
clientOfficeId, clientExternalId, groupData, loanType, loanProductId, loanProductName, loanProductDescription,
@@ -1267,7 +1268,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
loanScheduleProcessingType.asEnumOptionData(), fixedLength, chargeOffBehaviour.getValueAsStringEnumOptionData(),
interestRecognitionOnDisbursementDate, daysInYearCustomStrategy, enableIncomeCapitalization,
capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee,
buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType);
buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee);
}
}
@@ -201,7 +201,8 @@ public final class LoanProductDataValidator {
LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME,
LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME,
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME,
LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), LoanProductAccountingParams.INCOME_FROM_BUY_DOWN.getValue()));
LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), LoanProductAccountingParams.INCOME_FROM_BUY_DOWN.getValue(),
LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME));
private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName,
LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName,
@@ -2838,14 +2839,21 @@ public final class LoanProductDataValidator {
baseDataValidator.reset().parameter(LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME).value(buyDownFeeIncomeType)
.isOneOfEnumValues(LoanBuyDownFeeIncomeType.class)
.cantBeBlankWhenParameterProvidedIs(LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, true);
Boolean merchantBuyDownFee = this.fromApiJsonHelper
.extractBooleanNamed(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME, element);
baseDataValidator.reset().parameter(LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME).value(merchantBuyDownFee)
.ignoreIfNull().validateForBooleanValue();
// Accounting
if (AccountingValidations.isAccrualBasedAccounting(accountingRuleType)) {
final Long deferredIncomeLiabilityAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), element);
baseDataValidator.reset().parameter(LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue())
.value(deferredIncomeLiabilityAccountId).notNull()
.cantBeBlankWhenParameterProvidedIs(LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, true)
.integerGreaterThanZero();
if (merchantBuyDownFee) {
final Long deferredIncomeLiabilityAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), element);
baseDataValidator.reset().parameter(LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue())
.value(deferredIncomeLiabilityAccountId).notNull()
.cantBeBlankWhenParameterProvidedIs(LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, true)
.integerGreaterThanZero();
}
final Long incomeFromDeferredIncomeAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_BUY_DOWN.getValue(), element);
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_BUY_DOWN.getValue())
@@ -299,7 +299,7 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo
+ "lp.enable_income_capitalization as enableIncomeCapitalization, " //
+ "lp.capitalized_income_calculation_type as capitalizedIncomeCalculationType, " //
+ "lp.capitalized_income_strategy as capitalizedIncomeStrategy, " //
+ "lp.capitalized_income_type as capitalizedIncomeType, " //
+ "lp.capitalized_income_type as capitalizedIncomeType, lp.is_merchant_buy_down_fee as merchantBuyDownFee, " //
+ "lp.enable_buy_down_fee as enableBuyDownFee, " + "lp.buy_down_fee_calculation_type as buyDownFeeCalculationType, "
+ "lp.buy_down_fee_strategy as buyDownFeeStrategy, " + "lp.buy_down_fee_income_type as buyDownFeeIncomeType "
+ " from m_product_loan lp " + " left join m_fund f on f.id = lp.fund_id "
@@ -579,6 +579,7 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo
rs.getString("buyDownFeeStrategy"));
final StringEnumOptionData buyDownFeeIncomeType = ApiFacingEnum.getStringEnumOptionData(LoanBuyDownFeeIncomeType.class,
rs.getString("buyDownFeeIncomeType"));
final boolean merchantBuyDownFee = rs.getBoolean("merchantBuyDownFee");
return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance,
numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod,
@@ -604,7 +605,8 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo
fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes,
loanChargeOffBehaviour.getValueAsStringEnumOptionData(), interestRecognitionOnDisbursementDate,
daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy,
capitalizedIncome, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType);
capitalizedIncome, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType,
merchantBuyDownFee);
}
}
@@ -263,6 +263,7 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
enableIncomeCapitalization = (boolean) changes.get(LoanProductConstants.ENABLE_INCOME_CAPITALIZATION_PARAM_NAME);
}
boolean enableBuyDownFee = product.getLoanProductRelatedDetail().isEnableBuyDownFee();
boolean merchantBuyDownFee = product.getLoanProductRelatedDetail().isMerchantBuyDownFee();
if (changes.containsKey(LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME)) {
enableBuyDownFee = (boolean) changes.get(LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME);
}
@@ -271,7 +272,7 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
final boolean accountingTypeChanged = changes.containsKey("accountingRule");
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
.updateLoanProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingRule(),
enableIncomeCapitalization, enableBuyDownFee);
enableIncomeCapitalization, enableBuyDownFee, merchantBuyDownFee);
changes.putAll(accountingMappingChanges);
if (changes.containsKey(LoanProductConstants.RATES_PARAM_NAME)) {
@@ -72,7 +72,8 @@ class CreateJournalEntriesForChargeOffLoanTest {
transactionType, new BigDecimal("500.00"), new BigDecimal("500.00"), null, null, null, null, false, Collections.emptyList(),
Collections.emptyList(), false, "", null, null, null, null);
loanDTO = new LoanDTO(1L, 1L, 1L, "USD", false, true, true, List.of(loanTransactionDTO), false, false, chargeOffReasonId, false);
loanDTO = new LoanDTO(1L, 1L, 1L, "USD", false, true, true, List.of(loanTransactionDTO), false, false, chargeOffReasonId, false,
false);
}
@Test
@@ -91,7 +91,7 @@ public class DefaultScheduledDateGeneratorTest {
DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null, null, ZERO, null, NONE, null, ZERO,
EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false, null, false, DISBURSEMENT_DATE,
submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null, false, null, null, false, null, false, null, null,
null, false, null, null, null);
null, false, null, null, null, false);
// when
List<? extends LoanScheduleModelPeriod> result = underTest.generateRepaymentPeriods(mathContext, expectedDisbursementDate,
@@ -172,7 +172,7 @@ public class DefaultScheduledDateGeneratorTest {
EMPTY_LIST, BigDecimal.valueOf(36_000L), null, DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null,
null, ZERO, null, NONE, null, ZERO, EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false,
null, false, DISBURSEMENT_DATE, submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null, false, null, null,
false, null, false, null, null, null, false, null, null, null);
false, null, false, null, null, null, false, null, null, null, false);
}
private HolidayDetailDTO createHolidayDTO() {