From e10542838e9fada727b2ed0b08b73fec312df5e9 Mon Sep 17 00:00:00 2001 From: mariiaKraievska Date: Fri, 25 Apr 2025 18:22:08 +0300 Subject: [PATCH] FINERACT-2232: Capitalized income - Add Capitalized income to Loan --- ...uctToGLAccountMappingInvalidException.java | 2 +- .../service/CommandWrapperBuilder.java | 9 ++ .../data/loanproduct/DefaultLoanProduct.java | 1 + .../factory/LoanProductsRequestFactory.java | 117 ++++++++++++++++++ .../test/factory/LoanRequestFactory.java | 5 + .../LoanProductGlobalInitializerStep.java | 16 +++ .../test/stepdef/loan/LoanStepDef.java | 19 +++ .../fineract/test/support/TestContextKey.java | 2 + .../features/LoanCapitalizedIncome.feature | 23 ++++ .../api/LoanTransactionApiConstants.java | 1 + .../loanaccount/domain/LoanTransaction.java | 6 + .../AddCapitalizedIncomeCommandHandler.java | 53 ++++++++ .../service/LoanWritePlatformService.java | 2 + ...dd_capitalized_income_transaction_type.xml | 34 +++++ ...ccrualBasedAccountingProcessorForLoan.java | 41 ++++++ .../api/LoanTransactionsApiResource.java | 4 + .../LoanTransactionValidator.java | 90 ++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 47 +++++++ 18 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/LoanCapitalizedIncome.feature create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/AddCapitalizedIncomeCommandHandler.java create mode 100644 fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1027_add_capitalized_income_transaction_type.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/exception/ProductToGLAccountMappingInvalidException.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/exception/ProductToGLAccountMappingInvalidException.java index 1d6717be5..192dca069 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/exception/ProductToGLAccountMappingInvalidException.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/exception/ProductToGLAccountMappingInvalidException.java @@ -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); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 1d5b01451..a3389825d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -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; + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index e5e1d4d0d..2fcc9dbc6 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -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 diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java index 27aeb0ae0..0449730cf 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java @@ -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 principalVariationsForBorrowerCycle = new ArrayList<>(); + final List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); + final List interestRateVariationsForBorrowerCycle = new ArrayList<>(); + final List charges = new ArrayList<>(); + final List penaltyToIncomeAccountMappings = new ArrayList<>(); + final List feeToIncomeAccountMappings = new ArrayList<>(); + final List 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);// + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanRequestFactory.java index 29fbe7a57..560250631 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanRequestFactory.java @@ -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"); + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index b2b8f492e..3642a7513 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -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 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, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java index 35f942855..8503fbe78 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java @@ -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 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 capitalizedIncomeResponse = loanTransactionsApi + .executeLoanTransaction(loanId, capitalizedIncomeRequest, "capitalizedIncome").execute(); + testContext().set(TestContextKey.LOAN_CAPITALIZED_INCOME_RESPONSE, capitalizedIncomeResponse); + ErrorHelper.checkSuccessfulApiCall(capitalizedIncomeResponse); + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index a8c1b73c9..5236f6d3b 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -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"; } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanCapitalizedIncome.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanCapitalizedIncome.feature new file mode 100644 index 000000000..144ab7201 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanCapitalizedIncome.feature @@ -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 | diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java index affae3223..bb0de9cf8 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionApiConstants.java @@ -52,5 +52,6 @@ public interface LoanTransactionApiConstants { accrualActivity, // interestRefund, // accrualAdjustment, // + capitalizedIncome, // } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index d65fb36ed..25e4c8ba1 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -306,6 +306,12 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom + + + + + + + + + + + + diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java index 53f498143..722966e21 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java @@ -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 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 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); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java index 386ed322d..2a410513e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java @@ -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 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java index 80c7a7a47..dba73f3d7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidator.java @@ -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 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>() {}.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 getCapitalizedIncomeParameters() { + return new HashSet<>( + Arrays.asList("transactionDate", "dateFormat", "locale", "transactionAmount", "paymentTypeId", "note", "externalId")); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 88ce0dadd..558e9be45 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -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 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 existingTransactionIds = new ArrayList<>(loan.findExistingTransactionIds()); + final List 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);