FINERACT-2232: Capitalized income - Add Capitalized income to Loan

This commit is contained in:
mariiaKraievska
2025-04-30 10:23:55 +02:00
committed by Adam Saghy
parent bb0bdb9670
commit e10542838e
18 changed files with 471 additions and 1 deletions
@@ -29,7 +29,7 @@ public class ProductToGLAccountMappingInvalidException extends AbstractPlatformD
final String actualAccountCategory, final String expectedAccountCategory) {
super("error.msg." + paramName + ".invalid.account.type",
"Passed in GLAccount " + paramName + " with Id " + accountId + "maps to the account " + accountName + " of type "
+ actualAccountCategory + ", the expected account type was one among" + expectedAccountCategory,
+ actualAccountCategory + ", the expected account type was one among " + expectedAccountCategory,
paramName, accountId, accountName, actualAccountCategory, expectedAccountCategory);
}
}
@@ -3822,4 +3822,13 @@ public class CommandWrapperBuilder {
this.href = "/v1/loans/external-id/" + loanExternalId + "/interest-pauses/" + variationId;
return this;
}
public CommandWrapperBuilder addCapitalizedIncome(final Long loanId) {
this.actionName = "CAPITALIZEDINCOME";
this.entityName = "LOAN";
this.entityId = loanId;
this.loanId = loanId;
this.href = "/loans/" + loanId;
return this;
}
}
@@ -120,6 +120,7 @@ public enum DefaultLoanProduct implements LoanProduct {
LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF, //
LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_NO_INTEREST_RECALC_INT_REFUND_FULL_ACC_MATUR_CHARGE_OFF, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_IR_DAILY_TILL_REST_FREQUENCY_DATE_LAST_INSTALLMENT, //
LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_CAPITALIZED_INCOME, //
;
@Override
@@ -1500,4 +1500,121 @@ public class LoanProductsRequestFactory {
.chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))//
.incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));//
}
public PostLoanProductsRequest defaultLoanProductsRequestLP2CapitalizedIncome() {
final String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 4);
final String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3);
final List<Integer> principalVariationsForBorrowerCycle = new ArrayList<>();
final List<Integer> numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>();
final List<Integer> interestRateVariationsForBorrowerCycle = new ArrayList<>();
final List<LoanProductChargeData> charges = new ArrayList<>();
final List<LoanProductChargeToGLAccountMapper> penaltyToIncomeAccountMappings = new ArrayList<>();
final List<LoanProductChargeToGLAccountMapper> feeToIncomeAccountMappings = new ArrayList<>();
final List<GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings = new ArrayList<>();
final GetLoanPaymentChannelToFundSourceMappings loanPaymentChannelToFundSourceMappings = new GetLoanPaymentChannelToFundSourceMappings();
loanPaymentChannelToFundSourceMappings.fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.FUND_RECEIVABLES));
loanPaymentChannelToFundSourceMappings.paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER));
paymentChannelToFundSourceMappings.add(loanPaymentChannelToFundSourceMappings);
return new PostLoanProductsRequest()//
.name(name)//
.shortName(shortName)//
.description(DESCRIPTION_LP2)//
.enableDownPayment(true)//
.enableAutoRepaymentForDownPayment(true)//
.disbursedAmountPercentageForDownPayment(new BigDecimal(25))//
.fundId(FUND_ID)//
.startDate(null)//
.closeDate(null)//
.includeInBorrowerCycle(false)//
.currencyCode(CURRENCY_CODE)//
.digitsAfterDecimal(2)//
.inMultiplesOf(0)//
.installmentAmountInMultiplesOf(1)//
.useBorrowerCycle(false)//
.minPrincipal(100.0)//
.principal(1000.0)//
.maxPrincipal(10000.0)//
.minNumberOfRepayments(1)//
.numberOfRepayments(3)//
.maxNumberOfRepayments(30)//
.isLinkedToFloatingInterestRates(false)//
.minInterestRatePerPeriod((double) 0)//
.interestRatePerPeriod((double) 0)//
.maxInterestRatePerPeriod((double) 0)//
.interestRateFrequencyType(INTEREST_RATE_FREQUENCY_TYPE_MONTH)//
.repaymentEvery(15)//
.repaymentFrequencyType(REPAYMENT_FREQUENCY_TYPE_DAYS)//
.principalVariationsForBorrowerCycle(principalVariationsForBorrowerCycle)//
.numberOfRepaymentVariationsForBorrowerCycle(numberOfRepaymentVariationsForBorrowerCycle)//
.interestRateVariationsForBorrowerCycle(interestRateVariationsForBorrowerCycle)//
.amortizationType(AMORTIZATION_TYPE)//
.interestType(INTEREST_TYPE_DECLINING_BALANCE)//
.isEqualAmortization(false)//
.interestCalculationPeriodType(INTEREST_CALCULATION_PERIOD_TYPE_SAME_AS_REPAYMENT)//
.transactionProcessingStrategyCode(TRANSACTION_PROCESSING_STRATEGY_CODE)//
.daysInYearType(DAYS_IN_YEAR_TYPE)//
.daysInMonthType(DAYS_IN_MONTH_TYPE)//
.canDefineInstallmentAmount(true)//
.graceOnArrearsAgeing(3)//
.overdueDaysForNPA(179)//
.accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
.principalThresholdForLastInstallment(50)//
.allowVariableInstallments(false)//
.canUseForTopup(false)//
.isInterestRecalculationEnabled(false)//
.holdGuaranteeFunds(false)//
.multiDisburseLoan(true)//
.allowAttributeOverrides(new AllowAttributeOverrides()//
.amortizationType(true)//
.interestType(true)//
.transactionProcessingStrategyCode(true)//
.interestCalculationPeriodType(true)//
.inArrearsTolerance(true)//
.repaymentEvery(true)//
.graceOnPrincipalAndInterestPayment(true)//
.graceOnArrearsAgeing(true))//
.allowPartialPeriodInterestCalcualtion(true)//
.maxTrancheCount(10)//
.outstandingLoanBalance(10000.0)//
.charges(charges)//
.accountingRule(LOAN_ACCOUNTING_RULE)//
.fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.SUSPENSE_CLEARING_ACCOUNT))//
.loanPortfolioAccountId(accountTypeResolver.resolve(DefaultAccountType.LOANS_RECEIVABLE))//
.transfersInSuspenseAccountId(accountTypeResolver.resolve(DefaultAccountType.TRANSFER_IN_SUSPENSE_ACCOUNT))//
.interestOnLoanAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME))//
.incomeFromFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))//
.incomeFromPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))//
.incomeFromRecoveryAccountId(accountTypeResolver.resolve(DefaultAccountType.RECOVERIES))//
.writeOffAccountId(accountTypeResolver.resolve(DefaultAccountType.WRITTEN_OFF))//
.overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OVERPAYMENT_ACCOUNT))//
.receivableInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.receivableFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.receivablePenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.dateFormat(DATE_FORMAT)//
.locale(LOCALE_EN)//
.disallowExpectedDisbursements(true)//
.allowApprovedDisbursedAmountsOverApplied(true)//
.overAppliedCalculationType(OVER_APPLIED_CALCULATION_TYPE)//
.overAppliedNumber(OVER_APPLIED_NUMBER)//
.delinquencyBucketId(DELINQUENCY_BUCKET_ID.longValue())//
.goodwillCreditAccountId(accountTypeResolver.resolve(DefaultAccountType.GOODWILL_EXPENSE_ACCOUNT))//
.incomeFromGoodwillCreditInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))//
.incomeFromGoodwillCreditFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.incomeFromGoodwillCreditPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.paymentChannelToFundSourceMappings(paymentChannelToFundSourceMappings)//
.penaltyToIncomeAccountMappings(penaltyToIncomeAccountMappings)//
.feeToIncomeAccountMappings(feeToIncomeAccountMappings)//
.incomeFromChargeOffInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))//
.incomeFromChargeOffFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.chargeOffExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))//
.chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))//
.incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.capitalizedIncomeStrategy(PostLoanProductsRequest.CapitalizedIncomeStrategyEnum.EQUAL_AMORTIZATION)//
.capitalizedIncomeCalculationType(PostLoanProductsRequest.CapitalizedIncomeCalculationTypeEnum.FLAT)//
.capitalizedIncomeType(PostLoanProductsRequest.CapitalizedIncomeTypeEnum.INTEREST)
.deferredIncomeLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OVERPAYMENT_ACCOUNT))
.incomeFromCapitalizationAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME))
.enableIncomeCapitalization(true);//
}
}
@@ -285,4 +285,9 @@ public class LoanRequestFactory {
return new InterestPauseRequestDto().dateFormat(DATE_FORMAT).locale(DEFAULT_LOCALE).startDate(DEFAULT_TRANSACTION_DATE)
.endDate(DEFAULT_TRANSACTION_DATE);
}
public static PostLoansLoanIdTransactionsRequest defaultCapitalizedIncomeRequest() {
return new PostLoansLoanIdTransactionsRequest().transactionDate(DEFAULT_TRANSACTION_DATE).dateFormat(DATE_FORMAT)
.locale(DEFAULT_LOCALE).note("Capitalized Income");
}
}
@@ -2405,6 +2405,22 @@ public class LoanProductGlobalInitializerStep implements FineractGlobalInitializ
TestContext.INSTANCE.set(
TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_IR_DAILY_TILL_REST_FREQUENCY_DATELAST_INSTALLMENT,
responseLoanProductsRequestLP2AdvancedpaymentInterest36030InterestRecalcDailyTillRestFrequencyDateLastInstallment);
final String name97 = DefaultLoanProduct.LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_CAPITALIZED_INCOME.getName();
final PostLoanProductsRequest loanProductsRequestLP2ProgressiveAdvPaymentCapitalizedIncome = loanProductsRequestFactory
.defaultLoanProductsRequestLP2CapitalizedIncome()//
.name(name97)//
.transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION.getValue())//
.loanScheduleType("PROGRESSIVE") //
.paymentAllocation(List.of(//
createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), //
createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"), //
createPaymentAllocation("MERCHANT_ISSUED_REFUND", "REAMORTIZATION"), //
createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT")));//
final Response<PostLoanProductsResponse> responseLoanProductsRequestLP2ProgressiveAdvPaymentCapitalizedIncome = loanProductsApi
.createLoanProduct(loanProductsRequestLP2ProgressiveAdvPaymentCapitalizedIncome).execute();
TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_CAPITALIZED_INCOME,
responseLoanProductsRequestLP2ProgressiveAdvPaymentCapitalizedIncome);
}
public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule,
@@ -3863,4 +3863,23 @@ public class LoanStepDef extends AbstractStepDef {
assertNotNull(targetProduct.getInterestRecognitionOnDisbursementDate());
assertThat(targetProduct.getInterestRecognitionOnDisbursementDate().toString()).isEqualTo(expectedValue);
}
@And("Admin adds capitalized income with {string} payment type to the loan on {string} with {string} EUR transaction amount")
public void adminAddsCapitalizedIncomeToTheLoanOnWithEURTransactionAmount(final String transactionPaymentType,
final String transactionDate, final String amount) throws IOException {
final Response<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
final long loanId = loanResponse.body().getLoanId();
final DefaultPaymentType paymentType = DefaultPaymentType.valueOf(transactionPaymentType);
final Long paymentTypeValue = paymentTypeResolver.resolve(paymentType);
final PostLoansLoanIdTransactionsRequest capitalizedIncomeRequest = LoanRequestFactory.defaultCapitalizedIncomeRequest()
.transactionDate(transactionDate).transactionAmount(Double.valueOf(amount)).paymentTypeId(paymentTypeValue)
.externalId("EXT-CAP-INC-" + UUID.randomUUID());
final Response<PostLoansLoanIdTransactionsResponse> capitalizedIncomeResponse = loanTransactionsApi
.executeLoanTransaction(loanId, capitalizedIncomeRequest, "capitalizedIncome").execute();
testContext().set(TestContextKey.LOAN_CAPITALIZED_INCOME_RESPONSE, capitalizedIncomeResponse);
ErrorHelper.checkSuccessfulApiCall(capitalizedIncomeResponse);
}
}
@@ -145,6 +145,7 @@ public abstract class TestContextKey {
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_EXPECT_TRANCHE = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyMultidisburseExpectTranche";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE = "loanProductCreateResponseLP2AdvancedPaymentInterestRecalculationDailyEmi36030Multidisburse";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CASH_ACCOUNTING_DISBURSEMENT_CHARGES = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyCashBasedDisbursementCharge";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_CAPITALIZED_INCOME = "loanProductCreateResponseLP2ProgressiveAdvancedPaymentCapitalizedIncome";
public static final String LP2_NO_INTEREST_RECALCULATION_CHARGEBACK_ALLOCATION_INTEREST_FIRST_RESPONSE = "loanProductCreateResponseLP2NoInterestRecalculationChargebackAllocationInterestFirst";
public static final String LP2_NO_INTEREST_RECALCULATION_CHARGEBACK_ALLOCATION_PRINCIPAL_FIRST_RESPONSE = "loanProductCreateResponseLP2NoInterestRecalculationChargebackAllocationPrincipalFirst";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_INTEREST_RECALCULATION_ACCELERATE_MATURITY_CHARGE_OFF_BEHAVIOUR_LAST_INSTALLMENT_STRATEGY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyInterestRecalculationAccelerateMaturityChargeOffBehaviourLastInstallmentStrategy";
@@ -203,4 +204,5 @@ public abstract class TestContextKey {
public static final String LOAN_WRITE_OFF_RESPONSE = "loanWriteOffResponse";
public static final String LOAN_DELINQUENCY_ACTION_RESPONSE = "loanDelinquencyActionResponse";
public static final String LOAN_TRANSACTION_RESPONSE = "loanTransactionResponse";
public static final String LOAN_CAPITALIZED_INCOME_RESPONSE = "loanCapitalizedIncomeResponse";
}
@@ -0,0 +1,23 @@
@CapitalizedIncomeFeature
Feature: Capitalized Income
Scenario: As a user I want to add capitalized income to a progressive loan after disbursement
When Admin sets the business date to "1 January 2024"
And Admin creates a client with random data
When Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_CAPITALIZED_INCOME | 1 January 2024 | 1000.0 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 30 | DAYS | 30 | DAYS | 1 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
And Admin successfully approves the loan on "1 January 2024" with "1000" amount and expected disbursement date on "1 January 2024"
And Admin successfully disburse the loan on "1 January 2024" with "900" EUR transaction amount
Then Loan status will be "ACTIVE"
When Admin sets the business date to "2 January 2024"
And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "2 January 2024" with "100" EUR transaction amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 900.0 | false |
| 01 January 2024 | Down Payment | 225.0 | 225.0 | 0.0 | 0.0 | 0.0 | 675.0 | false |
| 02 January 2024 | Capitalized Income | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 775.0 | false |
Then Loan Transactions tab has a "CAPITALIZED_INCOME" transaction with date "02 January 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | 100.0 | |
| LIABILITY | 145024 | Deferred Income | | 100.0 |
@@ -52,5 +52,6 @@ public interface LoanTransactionApiConstants {
accrualActivity, //
interestRefund, //
accrualAdjustment, //
capitalizedIncome, //
}
}
@@ -306,6 +306,12 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
return newTransaction;
}
public static LoanTransaction capitalizedIncome(final Loan loan, final Money amount, final PaymentDetail paymentDetail,
final LocalDate transactionDate, final ExternalId externalId) {
return new LoanTransaction(null, loan.getOffice(), LoanTransactionType.CAPITALIZED_INCOME, paymentDetail, amount.getAmount(),
transactionDate, externalId);
}
public LoanTransaction copyTransactionPropertiesAndMappings() {
LoanTransaction newTransaction = copyTransactionProperties(this);
newTransaction.updateLoanTransactionToRepaymentScheduleMappings(loanTransactionToRepaymentScheduleMappings);
@@ -0,0 +1,53 @@
/**
* 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.portfolio.loanaccount.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.DataIntegrityErrorHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.loanaccount.service.LoanWritePlatformService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@CommandType(entity = "LOAN", action = "CAPITALIZEDINCOME")
public class AddCapitalizedIncomeCommandHandler implements NewCommandSourceHandler {
private final LoanWritePlatformService writePlatformService;
private final DataIntegrityErrorHandler dataIntegrityErrorHandler;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
try {
return this.writePlatformService.addCapitalizedIncome(command.getLoanId(), command);
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
dataIntegrityErrorHandler.handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve, "loan.capitalized.income",
"Capitalized Income");
return CommandProcessingResult.empty();
}
}
}
@@ -125,4 +125,6 @@ public interface LoanWritePlatformService {
CommandProcessingResult undoChargeOff(JsonCommand command);
CommandProcessingResult makeRefund(Long loanId, LoanTransactionType loanTransactionType, JsonCommand command);
CommandProcessingResult addCapitalizedIncome(Long loanId, JsonCommand command);
}
@@ -0,0 +1,34 @@
<?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 author="fineract" id="1">
<insert tableName="r_enum_value">
<column name="enum_name" value="transaction_type_enum"/>
<column name="enum_id" valueNumeric="35"/>
<column name="enum_message_property" value="Capitalized Income"/>
<column name="enum_value" value="Capitalized Income"/>
<column name="enum_type" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -123,6 +123,47 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
else if (transactionType.isInterestPaymentWaiver() || transactionType.isInterestRefund()) {
createJournalEntriesForInterestPaymentWaiverOrInterestRefund(loanDTO, loanTransactionDTO, office);
}
// Handle Capitalized Income
if (transactionType.isCapitalizedIncome()) {
createJournalEntriesForCapitalizedIncome(loanDTO, loanTransactionDTO, office);
}
}
}
private void createJournalEntriesForCapitalizedIncome(final LoanDTO loanDTO, final LoanTransactionDTO loanTransactionDTO,
final Office office) {
// loan properties
final Long loanProductId = loanDTO.getLoanProductId();
final Long loanId = loanDTO.getLoanId();
final String currencyCode = loanDTO.getCurrencyCode();
// transaction properties
final String transactionId = loanTransactionDTO.getTransactionId();
final LocalDate transactionDate = loanTransactionDTO.getTransactionDate();
final BigDecimal principalAmount = loanTransactionDTO.getPrincipal();
final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId();
GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder();
if (MathUtil.isGreaterThanZero(principalAmount)) {
populateCreditDebitMaps(loanProductId, principalAmount, paymentTypeId,
AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), AccrualAccountsForLoan.LOAN_PORTFOLIO.getValue(),
glAccountBalanceHolder);
}
// create credit entries
for (Map.Entry<Long, BigDecimal> creditEntry : glAccountBalanceHolder.getCreditBalances().entrySet()) {
if (MathUtil.isGreaterThanZero(creditEntry.getValue())) {
GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(creditEntry.getKey());
this.helper.createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate,
creditEntry.getValue(), glAccount);
}
}
// create debit entries
for (Map.Entry<Long, BigDecimal> debitEntry : glAccountBalanceHolder.getDebitBalances().entrySet()) {
if (MathUtil.isGreaterThanZero(debitEntry.getValue())) {
GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(debitEntry.getKey());
this.helper.createDebitJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate,
debitEntry.getValue(), glAccount);
}
}
}
@@ -90,6 +90,7 @@ public class LoanTransactionsApiResource {
public static final String REAGE = "reAge";
public static final String REAMORTIZE = "reAmortize";
public static final String UNDO_REAMORTIZE = "undoReAmortize";
public static final String CAPITALIZED_INCOME = "capitalizedIncome";
private final Set<String> responseDataParameters = new HashSet<>(Arrays.asList("id", "type", "date", "currency", "amount", "externalId",
LoanApiConstants.REVERSAL_EXTERNAL_ID_PARAMNAME, LoanApiConstants.REVERSED_ON_DATE_PARAMNAME));
@@ -526,6 +527,7 @@ public class LoanTransactionsApiResource {
case accrualActivity -> LoanTransactionType.ACCRUAL_ACTIVITY;
case interestRefund -> LoanTransactionType.INTEREST_REFUND;
case accrualAdjustment -> LoanTransactionType.ACCRUAL_ADJUSTMENT;
case capitalizedIncome -> LoanTransactionType.CAPITALIZED_INCOME;
default ->
throw new InvalidLoanTransactionTypeException("transaction", transactionTypeParam.name(), "Unknown transaction type");
};
@@ -583,6 +585,8 @@ public class LoanTransactionsApiResource {
commandRequest = builder.reAmortize(resolvedLoanId).build();
} else if (CommandParameterUtil.is(commandParam, UNDO_REAMORTIZE)) {
commandRequest = builder.undoReAmortize(resolvedLoanId).build();
} else if (CommandParameterUtil.is(commandParam, CAPITALIZED_INCOME)) {
commandRequest = builder.addCapitalizedIncome(resolvedLoanId).build();
}
if (commandRequest == null) {
@@ -69,6 +69,8 @@ import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement;
import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails;
import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent;
@@ -105,6 +107,7 @@ public final class LoanTransactionValidator {
private final EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService;
private final CalendarInstanceRepository calendarInstanceRepository;
private final LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator;
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
@@ -1034,4 +1037,91 @@ public final class LoanTransactionValidator {
loan.getRescheduledOnDate());
}
}
public void validateCapitalizedIncome(final JsonCommand command, final Long loanId) {
final String json = command.json();
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final JsonElement element = this.fromApiJsonHelper.parse(json);
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, getCapitalizedIncomeParameters());
LoanApplicationValidator.validateOrThrow("loan.capitalized.income", baseDataValidator -> {
final Loan loan = this.loanRepositoryWrapper.findOneWithNotFoundDetection(loanId, true);
validateLoanClientIsActive(loan);
validateLoanGroupIsActive(loan);
// Validate that loan is disbursed
if (!loan.isDisbursed()) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("capitalized.income.only.after.disbursement",
"Capitalized income can be added to the loan only after Disbursement");
}
// Validate loan is progressive
if (!loan.isProgressiveSchedule()) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.progressive.loan");
}
// Validate income capitalization is enabled
if (!loan.getLoanProductRelatedDetail().isEnableIncomeCapitalization()) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("income.capitalization.not.enabled");
}
// Validate loan is active
if (!loan.getStatus().isActive()) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.active");
}
final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element);
baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull();
// Validate transaction date is not before disbursement date
if (transactionDate != null && loan.getDisbursementDate() != null && transactionDate.isBefore(loan.getDisbursementDate())) {
baseDataValidator.reset().parameter("transactionDate").failWithCode("before.disbursement.date",
"Transaction date cannot be before disbursement date");
}
final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element);
baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).notNull().positiveAmount();
// Validate total disbursement + capitalized income <= approved amount
if (transactionAmount != null) {
final BigDecimal totalDisbursed = loan.getDisbursedAmount();
final BigDecimal existingCapitalizedIncomeBalance = loanCapitalizedIncomeBalanceRepository.findAllByLoanId(loanId).stream()
.map(LoanCapitalizedIncomeBalance::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
final BigDecimal approvedAmount = loan.getApprovedPrincipal();
final BigDecimal newTotal = totalDisbursed.add(existingCapitalizedIncomeBalance).add(transactionAmount);
if (newTotal.compareTo(approvedAmount) > 0) {
baseDataValidator.reset().parameter("transactionAmount").failWithCode("exceeds.approved.amount",
"Sum of disbursed amount and capitalized income cannot exceed approved amount");
}
}
validatePaymentDetails(baseDataValidator, element);
validateNote(baseDataValidator, element);
validateExternalId(baseDataValidator, element);
});
}
private void validateNote(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
final String note = this.fromApiJsonHelper.extractStringNamed("note", element);
if (StringUtils.isNotBlank(note)) {
baseDataValidator.reset().parameter("note").value(note).notExceedingLengthOf(1000);
}
}
private void validateExternalId(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
final String externalId = this.fromApiJsonHelper.extractStringNamed("externalId", element);
if (StringUtils.isNotBlank(externalId)) {
baseDataValidator.reset().parameter("externalId").value(externalId).notExceedingLengthOf(100);
}
}
private Set<String> getCapitalizedIncomeParameters() {
return new HashSet<>(
Arrays.asList("transactionDate", "dateFormat", "locale", "transactionAmount", "paymentTypeId", "note", "externalId"));
}
}
@@ -2996,6 +2996,53 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
.build();
}
@Transactional
@Override
public CommandProcessingResult addCapitalizedIncome(final Long loanId, final JsonCommand command) {
loanTransactionValidator.validateCapitalizedIncome(command, loanId);
final Loan loan = loanAssembler.assembleFrom(loanId);
final AppUser currentUser = getAppUserIfPresent();
final Map<String, Object> changes = new LinkedHashMap<>();
// Create payment details
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
// Validate cash payment if applicable
if (paymentDetail != null && paymentDetail.getPaymentType() != null && paymentDetail.getPaymentType().getIsCashPayment()) {
BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
this.cashierTransactionDataValidator.validateOnLoanDisbursal(currentUser, loan.getCurrencyCode(), transactionAmount);
}
// Extract transaction details
final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
final ExternalId txnExternalId = externalIdFactory.createFromCommand(command, "externalId");
// Create capitalized income transaction
final Money capitalizedIncomeAmount = Money.of(loan.getCurrency(), transactionAmount);
final LoanTransaction capitalizedIncomeTransaction = LoanTransaction.capitalizedIncome(loan, capitalizedIncomeAmount, paymentDetail,
transactionDate, txnExternalId);
// Update loan with capitalized income
capitalizedIncomeTransaction.updateLoan(loan);
loan.addLoanTransaction(capitalizedIncomeTransaction);
loanTransactionRepository.saveAndFlush(capitalizedIncomeTransaction);
// Update loan counters and save
loan.updateLoanScheduleDependentDerivedFields();
// Create note if provided
createNote(loan, command, changes);
// Post journal entries
final List<Long> existingTransactionIds = new ArrayList<>(loan.findExistingTransactionIds());
final List<Long> existingReversedTransactionIds = new ArrayList<>(loan.findExistingReversedTransactionIds());
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
return new CommandProcessingResultBuilder().withEntityId(loan.getId()).withEntityExternalId(loan.getExternalId()).build();
}
public void handleChargebackTransaction(final Loan loan, LoanTransaction chargebackTransaction) {
loanTransactionValidator.validateIfTransactionIsChargeback(chargebackTransaction);