From b8d6dfa794144b3a0971b8700eca6d6b36700610 Mon Sep 17 00:00:00 2001 From: "adam.magyari" Date: Mon, 7 Jul 2025 14:58:31 +0200 Subject: [PATCH] FINERACT-2311: Buy down fee - daily amortization --- .../test/helper/WorkFlowJobHelper.java | 5 +- ...stmentTransactionCreatedBusinessEvent.java | 35 ++++ ...zationTransactionCreatedBusinessEvent.java | 35 ++++ .../api/LoanTransactionApiConstants.java | 2 + .../data/LoanTransactionEnumData.java | 5 + .../loanaccount/domain/LoanTransaction.java | 24 ++- .../domain/LoanTransactionRepository.java | 17 +- .../domain/LoanTransactionType.java | 4 + ...yDownFeeAmortizationProcessingService.java | 28 +++ .../loanproduct/service/LoanEnumerations.java | 6 + ...ccrualBasedAccountingProcessorForLoan.java | 159 ++++++++++++++++++ .../BuyDownFeeAmortizationBusinessStep.java | 60 +++++++ .../api/LoanTransactionsApiResource.java | 2 + .../api/LoansApiResourceSwagger.java | 8 + ...nFeeAmortizationProcessingServiceImpl.java | 113 +++++++++++++ ...comeAmortizationProcessingServiceImpl.java | 8 +- .../starter/LoanAccountConfiguration.java | 24 +++ .../util/BuyDownFeeAmortizationUtil.java | 84 +++++++++ .../db/changelog/tenant/changelog-tenant.xml | 1 + .../parts/0190_buy_down_fee_amortization.xml | 49 ++++++ ...entConfigurationValidationServiceTest.java | 8 +- .../integrationtests/LoanBuyDownFeeTest.java | 148 ++++++++++++++++ .../ExternalEventConfigurationHelper.java | 11 ++ .../common/loans/LoanTransactionHelper.java | 5 + 24 files changed, 829 insertions(+), 12 deletions(-) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/cob/loan/BuyDownFeeAmortizationBusinessStep.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/util/BuyDownFeeAmortizationUtil.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0190_buy_down_fee_amortization.xml diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkFlowJobHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkFlowJobHelper.java index fcaf44d89..1a4f2ece3 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkFlowJobHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkFlowJobHelper.java @@ -50,8 +50,9 @@ public class WorkFlowJobHelper { new BusinessStep().stepName("ADD_PERIODIC_ACCRUAL_ENTRIES").order(7L), // new BusinessStep().stepName("ACCRUAL_ACTIVITY_POSTING").order(8L), // new BusinessStep().stepName("CAPITALIZED_INCOME_AMORTIZATION").order(9L), // - new BusinessStep().stepName("LOAN_INTEREST_RECALCULATION").order(10L), // - new BusinessStep().stepName("EXTERNAL_ASSET_OWNER_TRANSFER").order(11L)// + new BusinessStep().stepName("BUY_DOWN_FEE_AMORTIZATION").order(10L), // + new BusinessStep().stepName("LOAN_INTEREST_RECALCULATION").order(11L), // + new BusinessStep().stepName("EXTERNAL_ASSET_OWNER_TRANSFER").order(12L)// ); BusinessStepRequest request = new BusinessStepRequest().businessSteps(businessSteps); Response response = businessStepConfigurationApi.updateJobBusinessStepConfig(WORKFLOW_NAME_LOAN_CLOSE_OF_BUSINESS, request) diff --git a/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.java b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.java new file mode 100644 index 000000000..c8ef1b412 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * 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.infrastructure.event.business.domain.loan.transaction; + +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; + +public class LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent extends LoanTransactionBusinessEvent { + + private static final String TYPE = "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent"; + + public LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent(LoanTransaction transaction) { + super(transaction); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.java b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.java new file mode 100644 index 000000000..6abe1a53d --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * 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.infrastructure.event.business.domain.loan.transaction; + +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; + +public class LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent extends LoanTransactionBusinessEvent { + + private static final String TYPE = "LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent"; + + public LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent(LoanTransaction transaction) { + super(transaction); + } + + @Override + public String getType() { + return TYPE; + } +} 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 51d7df8d1..6617def50 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 @@ -57,5 +57,7 @@ public interface LoanTransactionApiConstants { capitalizedIncomeAdjustment, // contractTermination, // capitalizedIncomeAmortizationAdjustment, // + buyDownFeeAmortization, // + buyDownFeeAmortizationAdjustment, // } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java index 9de2b093d..743143453 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java @@ -74,6 +74,8 @@ public class LoanTransactionEnumData implements Serializable { private final boolean contractTermination; private final boolean buyDownFee; private final boolean buyDownFeeAdjustment; + private final boolean buyDownFeeAmortization; + private final boolean buyDownFeeAmortizationAdjustment; public LoanTransactionEnumData(final Long id, final String code, final String value) { this.id = id; @@ -118,6 +120,9 @@ public class LoanTransactionEnumData implements Serializable { this.contractTermination = Long.valueOf(LoanTransactionType.CONTRACT_TERMINATION.getValue()).equals(this.id); this.buyDownFee = Long.valueOf(LoanTransactionType.BUY_DOWN_FEE.getValue()).equals(this.id); this.buyDownFeeAdjustment = Long.valueOf(LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT.getValue()).equals(this.id); + this.buyDownFeeAmortization = Long.valueOf(LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION.getValue()).equals(this.id); + this.buyDownFeeAmortizationAdjustment = Long.valueOf(LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT.getValue()) + .equals(this.id); } public boolean isRepaymentType() { 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 9a1c48dd1..62c49d807 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 @@ -348,6 +348,26 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom new LoanTransaction(loan, office, LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION, dateOf, amount, null, null, amount, + null, null, false, null, externalId); + case INTEREST -> new LoanTransaction(loan, office, LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION, dateOf, amount, null, amount, + null, null, null, false, null, externalId); + }; + } + + public static LoanTransaction buyDownFeeAmortizationAdjustment(final Loan loan, final Money amount, final LocalDate transactionDate, + final ExternalId externalId) { + return switch (loan.getLoanProductRelatedDetail().getBuyDownFeeIncomeType()) { + case FEE -> new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT, + transactionDate, amount.getAmount(), null, null, amount.getAmount(), null, null, false, null, externalId); + case INTEREST -> new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT, + transactionDate, amount.getAmount(), null, amount.getAmount(), null, null, null, false, null, externalId); + }; + } + public LoanTransaction copyTransactionPropertiesAndMappings() { LoanTransaction newTransaction = copyTransactionProperties(this); newTransaction.updateLoanTransactionToRepaymentScheduleMappings(loanTransactionToRepaymentScheduleMappings); @@ -806,7 +826,9 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom findAdjustmentsForCapitalizedIncome(@Param("capitalizedIncome") LoanTransaction capitalizedIncome); + List findAdjustments(@Param("transaction") LoanTransaction transaction); @Query(""" SELECT lt FROM LoanTransaction lt diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java index 0e014d027..41489897c 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java @@ -76,6 +76,8 @@ public enum LoanTransactionType { BUY_DOWN_FEE(40, "loanTransactionType.buyDownFee"), // BUY_DOWN_FEE_ADJUSTMENT(41, "loanTransactionType.buyDownFeeAdjustment"), // + BUY_DOWN_FEE_AMORTIZATION(42, "loanTransactionType.buyDownFeeAmortization"), // + BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT(43, "loanTransactionType.buyDownFeeAmortizationAdjustment"), // ; private final Integer value; @@ -133,6 +135,8 @@ public enum LoanTransactionType { case 39 -> LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT; case 40 -> LoanTransactionType.BUY_DOWN_FEE; case 41 -> LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT; + case 42 -> LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION; + case 43 -> LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT; default -> LoanTransactionType.INVALID; }; } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingService.java new file mode 100644 index 000000000..8c1cc6a04 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingService.java @@ -0,0 +1,28 @@ +/** + * 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.service; + +import java.time.LocalDate; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.springframework.lang.NonNull; + +public interface LoanBuyDownFeeAmortizationProcessingService { + + void processBuyDownFeeAmortizationTillDate(@NonNull Loan loan, @NonNull LocalDate tillDate, boolean addJournal); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java index 9d9f0b0b4..6ff0c86c3 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java @@ -342,6 +342,12 @@ public final class LoanEnumerations { LoanTransactionType.BUY_DOWN_FEE.getCode(), "Buy Down Fee"); case BUY_DOWN_FEE_ADJUSTMENT -> new LoanTransactionEnumData(LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT.getValue().longValue(), LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT.getCode(), "Buy Down Fee Adjustment"); + case BUY_DOWN_FEE_AMORTIZATION -> + new LoanTransactionEnumData(LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION.getValue().longValue(), + LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION.getCode(), "Buy Down Fee Amortization"); + case BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT -> + new LoanTransactionEnumData(LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT.getValue().longValue(), + LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT.getCode(), "Buy Down Fee Amortization Adjustment"); }; } 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 9d5fbef25..481aefdb0 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 @@ -153,6 +153,14 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess if (transactionType.isBuyDownFeeAdjustment()) { createJournalEntriesForBuyDownFeeAdjustment(loanDTO, loanTransactionDTO, office); } + // Handle Buy Down Fee Amortization + if (transactionType.isBuyDownFeeAmortization()) { + createJournalEntriesForBuyDownFeeAmortization(loanDTO, loanTransactionDTO, office); + } + // Handle Buy Down Fee Amortization Adjustment + if (transactionType.isBuyDownFeeAmortizationAdjustment()) { + createJournalEntriesForBuyDownFeeAmortizationAdjustment(loanDTO, loanTransactionDTO, office); + } } } @@ -462,6 +470,157 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess } } + private void createJournalEntriesForBuyDownFeeAmortization(final LoanDTO loanDTO, final LoanTransactionDTO loanTransactionDTO, + final Office office) { + final boolean isMarkedAsChargeOff = loanDTO.isMarkedAsChargeOff(); + if (isMarkedAsChargeOff) { + createJournalEntriesForChargeOffLoanBuyDownFeeAmortization(loanDTO, loanTransactionDTO, office); + } else { + createJournalEntriesForLoanBuyDownFeeAmortization(loanDTO, loanTransactionDTO, office); + } + } + + private void createJournalEntriesForLoanBuyDownFeeAmortization(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(); + final boolean isLoanWrittenOff = loanDTO.isMarkedAsWrittenOff(); + + // transaction properties + final String transactionId = loanTransactionDTO.getTransactionId(); + final LocalDate transactionDate = loanTransactionDTO.getTransactionDate(); + final BigDecimal interestAmount = loanTransactionDTO.getInterest(); + final BigDecimal feesAmount = loanTransactionDTO.getFees(); + final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId(); + final GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder(); + + // interest payment + final AccrualAccountsForLoan creditAccountType = isLoanWrittenOff ? AccrualAccountsForLoan.LOSSES_WRITTEN_OFF + : AccrualAccountsForLoan.INCOME_FROM_BUY_DOWN; + if (MathUtil.isGreaterThanZero(interestAmount)) { + populateCreditDebitMaps(loanProductId, interestAmount, paymentTypeId, creditAccountType.getValue(), + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), glAccountBalanceHolder); + } + // handle fees payment + if (MathUtil.isGreaterThanZero(feesAmount)) { + populateCreditDebitMaps(loanProductId, feesAmount, paymentTypeId, creditAccountType.getValue(), + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.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); + } + } + } + + private void createJournalEntriesForChargeOffLoanBuyDownFeeAmortization(final LoanDTO loanDTO, + final LoanTransactionDTO loanTransactionDTO, final Office office) { + // loan properties + final Long loanProductId = loanDTO.getLoanProductId(); + final boolean isMarkedFraud = loanDTO.isMarkedAsFraud(); + final Long loanId = loanDTO.getLoanId(); + final String currencyCode = loanDTO.getCurrencyCode(); + + // transaction properties + final String transactionId = loanTransactionDTO.getTransactionId(); + final LocalDate transactionDate = loanTransactionDTO.getTransactionDate(); + final BigDecimal interestAmount = loanTransactionDTO.getInterest(); + final BigDecimal feesAmount = loanTransactionDTO.getFees(); + final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId(); + final GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder(); + final Long chargeOffReasonCodeValue = loanDTO.getChargeOffReasonCodeValue(); + + final ProductToGLAccountMapping mapping = chargeOffReasonCodeValue != null + ? helper.getChargeOffMappingByCodeValue(loanProductId, PortfolioProductType.LOAN, chargeOffReasonCodeValue) + : null; + + if (mapping != null) { + final GLAccount accountDebit = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), paymentTypeId); + // handle interest payment + if (MathUtil.isGreaterThanZero(interestAmount)) { + glAccountBalanceHolder.addToCredit(mapping.getGlAccount(), interestAmount); + glAccountBalanceHolder.addToDebit(accountDebit, interestAmount); + } + // handle fees payment + if (MathUtil.isGreaterThanZero(feesAmount)) { + glAccountBalanceHolder.addToCredit(mapping.getGlAccount(), feesAmount); + glAccountBalanceHolder.addToDebit(accountDebit, feesAmount); + } + } else { + final AccrualAccountsForLoan creditAccountType = isMarkedFraud ? AccrualAccountsForLoan.CHARGE_OFF_FRAUD_EXPENSE + : AccrualAccountsForLoan.CHARGE_OFF_EXPENSE; + // handle interest payment + if (MathUtil.isGreaterThanZero(interestAmount)) { + populateCreditDebitMaps(loanProductId, interestAmount, paymentTypeId, creditAccountType.getValue(), + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), glAccountBalanceHolder); + } + // handle fees payment + if (MathUtil.isGreaterThanZero(feesAmount)) { + populateCreditDebitMaps(loanProductId, feesAmount, paymentTypeId, creditAccountType.getValue(), + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.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); + } + } + } + + private void createJournalEntriesForBuyDownFeeAmortizationAdjustment(final LoanDTO loanDTO, final LoanTransactionDTO loanTransactionDTO, + final Office office) { + GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder(); + if (MathUtil.isGreaterThanZero(loanTransactionDTO.getAmount())) { + populateCreditDebitMaps(loanDTO.getLoanProductId(), loanTransactionDTO.getAmount(), loanTransactionDTO.getPaymentTypeId(), + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), AccrualAccountsForLoan.INCOME_FROM_BUY_DOWN.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, loanDTO.getCurrencyCode(), loanDTO.getLoanId(), + loanTransactionDTO.getTransactionId(), loanTransactionDTO.getTransactionDate(), 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, loanDTO.getCurrencyCode(), loanDTO.getLoanId(), + loanTransactionDTO.getTransactionId(), loanTransactionDTO.getTransactionDate(), debitEntry.getValue(), glAccount); + } + } + } + private void createJournalEntriesForInterestPaymentWaiverOrInterestRefund(LoanDTO loanDTO, LoanTransactionDTO loanTransactionDTO, Office office) { final Long loanProductId = loanDTO.getLoanProductId(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/BuyDownFeeAmortizationBusinessStep.java b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/BuyDownFeeAmortizationBusinessStep.java new file mode 100644 index 000000000..1f511c94a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/BuyDownFeeAmortizationBusinessStep.java @@ -0,0 +1,60 @@ +/** + * 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.cob.loan; + +import jakarta.transaction.Transactional; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.service.LoanBuyDownFeeAmortizationProcessingService; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class BuyDownFeeAmortizationBusinessStep implements LoanCOBBusinessStep { + + private final LoanBuyDownFeeAmortizationProcessingService loanBuyDownFeeAmortizationProcessingService; + + @Transactional + @Override + public Loan execute(Loan loan) { + if (!loan.getLoanProductRelatedDetail().isEnableBuyDownFee()) { + return loan; + } + + LocalDate businessDate = DateUtils.getBusinessLocalDate(); + + loanBuyDownFeeAmortizationProcessingService.processBuyDownFeeAmortizationTillDate(loan, businessDate, true); + + return loan; + } + + @Override + public String getEnumStyledName() { + return "BUY_DOWN_FEE_AMORTIZATION"; + } + + @Override + public String getHumanReadableName() { + return "Buy Down Fee amortization"; + } +} 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 039f7a70e..366d1d022 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 @@ -537,6 +537,8 @@ public class LoanTransactionsApiResource { case capitalizedIncomeAdjustment -> LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT; case contractTermination -> LoanTransactionType.CONTRACT_TERMINATION; case capitalizedIncomeAmortizationAdjustment -> LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT; + case buyDownFeeAmortization -> LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION; + case buyDownFeeAmortizationAdjustment -> LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT; default -> throw new InvalidLoanTransactionTypeException("transaction", transactionTypeParam.name(), "Unknown transaction type"); }; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index 44a8738b6..3d4b1cdbc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -735,6 +735,14 @@ final class LoansApiResourceSwagger { public boolean capitalizedIncomeAdjustment; @Schema(example = "false") public boolean contractTermination; + @Schema(example = "false") + public boolean buyDownFee; + @Schema(example = "false") + public boolean buyDownFeeAdjustment; + @Schema(example = "false") + public boolean buyDownFeeAmortization; + @Schema(example = "false") + public boolean buyDownFeeAmortizationAdjustment; } static final class GetLoansLoanIdPaymentDetailData { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingServiceImpl.java new file mode 100644 index 000000000..979bb4c06 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBuyDownFeeAmortizationProcessingServiceImpl.java @@ -0,0 +1,113 @@ +/** + * 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.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeBalance; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; +import org.apache.fineract.portfolio.loanaccount.repository.LoanBuyDownFeesBalanceRepository; +import org.apache.fineract.portfolio.loanaccount.util.BuyDownFeeAmortizationUtil; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class LoanBuyDownFeeAmortizationProcessingServiceImpl implements LoanBuyDownFeeAmortizationProcessingService { + + private final LoanTransactionRepository loanTransactionRepository; + private final LoanBuyDownFeesBalanceRepository loanBuyDownFeesBalanceRepository; + private final BusinessEventNotifierService businessEventNotifierService; + private final LoanJournalEntryPoster journalEntryPoster; + private final ExternalIdFactory externalIdFactory; + + @Override + @Transactional + public void processBuyDownFeeAmortizationTillDate(@NonNull Loan loan, @NonNull LocalDate tillDate, boolean addJournal) { + final List existingTransactionIds = loanTransactionRepository.findTransactionIdsByLoan(loan); + final List existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan); + + List balances = loanBuyDownFeesBalanceRepository.findAllByLoanId(loan.getId()); + + LocalDate maturityDate = loan.getMaturityDate() != null ? loan.getMaturityDate() + : getFinalBuyDownFeeAmortizationTransactionDate(loan); + LocalDate tillDatePlusOne = tillDate.plusDays(1); + if (tillDatePlusOne.isAfter(maturityDate)) { + tillDatePlusOne = maturityDate; + } + + Money totalAmortization = Money.zero(loan.getCurrency()); + for (LoanBuyDownFeeBalance balance : balances) { + List adjustments = loanTransactionRepository.findAdjustments(balance.getLoanTransaction()); + Money amortizationTillDate = BuyDownFeeAmortizationUtil.calculateTotalAmortizationTillDate(balance, adjustments, maturityDate, + loan.getLoanProductRelatedDetail().getBuyDownFeeStrategy(), tillDatePlusOne, loan.getCurrency()); + totalAmortization = totalAmortization.add(amortizationTillDate); + + balance.setUnrecognizedAmount(balance.getAmount().subtract(MathUtil.nullToZero(balance.getAmountAdjustment())) + .subtract(amortizationTillDate.getAmount())); + } + + loanBuyDownFeesBalanceRepository.saveAll(balances); + + BigDecimal totalAmortized = loanTransactionRepository.getAmortizedAmountBuyDownFee(loan); + BigDecimal totalAmortizationAmount = totalAmortization.getAmount().subtract(totalAmortized); + + if (!MathUtil.isZero(totalAmortizationAmount)) { + LoanTransaction transaction = MathUtil.isGreaterThanZero(totalAmortizationAmount) + ? LoanTransaction.buyDownFeeAmortization(loan, loan.getOffice(), tillDate, totalAmortizationAmount, + externalIdFactory.create()) + : LoanTransaction.buyDownFeeAmortizationAdjustment(loan, + Money.of(loan.getCurrency(), MathUtil.negate(totalAmortizationAmount)), tillDate, externalIdFactory.create()); + loan.addLoanTransaction(transaction); + + transaction = loanTransactionRepository.save(transaction); + loanTransactionRepository.flush(); + + if (addJournal) { + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + } + + BusinessEvent event = MathUtil.isGreaterThanZero(totalAmortizationAmount) + ? new LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent(transaction) + : new LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent(transaction); + businessEventNotifierService.notifyPostBusinessEvent(event); + } + } + + private LocalDate getFinalBuyDownFeeAmortizationTransactionDate(final Loan loan) { + return switch (loan.getStatus()) { + case CLOSED_OBLIGATIONS_MET -> loan.getClosedOnDate(); + case OVERPAID -> loan.getOverpaidOnDate(); + case CLOSED_WRITTEN_OFF -> loan.getWrittenOffOnDate(); + default -> throw new IllegalStateException("Unexpected value: " + loan.getStatus()); + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanCapitalizedIncomeAmortizationProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanCapitalizedIncomeAmortizationProcessingServiceImpl.java index 17f0cf534..e8bf678e1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanCapitalizedIncomeAmortizationProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanCapitalizedIncomeAmortizationProcessingServiceImpl.java @@ -125,7 +125,7 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L BigDecimal totalAmortizationAmount = BigDecimal.ZERO; for (LoanCapitalizedIncomeBalance balance : balances) { - List adjustments = loanTransactionRepository.findAdjustmentsForCapitalizedIncome(balance.getLoanTransaction()); + List adjustments = loanTransactionRepository.findAdjustments(balance.getLoanTransaction()); LocalDate maturityDate = loan.getMaturityDate() != null ? loan.getMaturityDate() : transactionDate; final Money amortizationTillDate = CapitalizedIncomeAmortizationUtil.calculateTotalAmortizationTillDate(balance, adjustments, maturityDate, loan.getLoanProductRelatedDetail().getCapitalizedIncomeStrategy(), maturityDate, loan.getCurrency()); @@ -136,7 +136,7 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L balance.setUnrecognizedAmount(BigDecimal.ZERO); } - BigDecimal amortizedAmount = loanTransactionRepository.getAmortizedAmount(loan); + BigDecimal amortizedAmount = loanTransactionRepository.getAmortizedAmountCapitalizedIncome(loan); BigDecimal totalUnrecognizedAmount = totalAmortizationAmount.subtract(amortizedAmount); if (MathUtil.isZero(totalUnrecognizedAmount)) { return Optional.empty(); @@ -203,7 +203,7 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L Money totalAmortization = Money.zero(loan.getCurrency()); for (LoanCapitalizedIncomeBalance balance : balances) { - List adjustments = loanTransactionRepository.findAdjustmentsForCapitalizedIncome(balance.getLoanTransaction()); + List adjustments = loanTransactionRepository.findAdjustments(balance.getLoanTransaction()); Money amortizationTillDate = CapitalizedIncomeAmortizationUtil.calculateTotalAmortizationTillDate(balance, adjustments, maturityDate, loan.getLoanProductRelatedDetail().getCapitalizedIncomeStrategy(), tillDatePlusOne, loan.getCurrency()); totalAmortization = totalAmortization.add(amortizationTillDate); @@ -214,7 +214,7 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L loanCapitalizedIncomeBalanceRepository.saveAll(balances); - BigDecimal totalAmortized = loanTransactionRepository.getAmortizedAmount(loan); + BigDecimal totalAmortized = loanTransactionRepository.getAmortizedAmountCapitalizedIncome(loan); BigDecimal totalAmortizationAmount = totalAmortization.getAmount().subtract(totalAmortized); if (!MathUtil.isZero(totalAmortizationAmount)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index 00609a999..a891a2aeb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -123,9 +123,12 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanArrearsAgingService import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; import org.apache.fineract.portfolio.loanaccount.service.LoanAssemblerImpl; import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService; +import org.apache.fineract.portfolio.loanaccount.service.LoanBuyDownFeeAmortizationProcessingService; +import org.apache.fineract.portfolio.loanaccount.service.LoanBuyDownFeeAmortizationProcessingServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.LoanCalculateRepaymentPastDueService; import org.apache.fineract.portfolio.loanaccount.service.LoanCapitalizedIncomeAmortizationEventService; import org.apache.fineract.portfolio.loanaccount.service.LoanCapitalizedIncomeAmortizationProcessingService; +import org.apache.fineract.portfolio.loanaccount.service.LoanCapitalizedIncomeAmortizationProcessingServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.LoanChargeAssembler; import org.apache.fineract.portfolio.loanaccount.service.LoanChargePaidByReadService; import org.apache.fineract.portfolio.loanaccount.service.LoanChargeReadPlatformService; @@ -549,4 +552,25 @@ public class LoanAccountConfiguration { loanCapitalizedIncomeAmortizationProcessingService); } + @Bean + @ConditionalOnMissingBean(LoanCapitalizedIncomeAmortizationProcessingService.class) + public LoanCapitalizedIncomeAmortizationProcessingService loanCapitalizedIncomeAmortizationProcessingService( + final ConfigurationDomainService configurationDomainService, final LoanTransactionRepository loanTransactionRepository, + final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository, + final BusinessEventNotifierService businessEventNotifierService, final LoanJournalEntryPoster journalEntryPoster, + final ExternalIdFactory externalIdFactory) { + return new LoanCapitalizedIncomeAmortizationProcessingServiceImpl(configurationDomainService, loanTransactionRepository, + loanCapitalizedIncomeBalanceRepository, businessEventNotifierService, journalEntryPoster, externalIdFactory); + } + + @Bean + @ConditionalOnMissingBean(LoanBuyDownFeeAmortizationProcessingService.class) + public LoanBuyDownFeeAmortizationProcessingService loanBuyDownFeeAmortizationProcessingService( + final LoanTransactionRepository loanTransactionRepository, + final LoanBuyDownFeesBalanceRepository loanBuyDownFeesBalanceRepository, + final BusinessEventNotifierService businessEventNotifierService, final LoanJournalEntryPoster journalEntryPoster, + final ExternalIdFactory externalIdFactory) { + return new LoanBuyDownFeeAmortizationProcessingServiceImpl(loanTransactionRepository, loanBuyDownFeesBalanceRepository, + businessEventNotifierService, journalEntryPoster, externalIdFactory); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/util/BuyDownFeeAmortizationUtil.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/util/BuyDownFeeAmortizationUtil.java new file mode 100644 index 000000000..37459b5f0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/util/BuyDownFeeAmortizationUtil.java @@ -0,0 +1,84 @@ +/** + * 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.util; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeBalance; +import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeStrategy; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; + +public final class BuyDownFeeAmortizationUtil { + + private BuyDownFeeAmortizationUtil() {} + + public static Money calculateTotalAmortizationTillDate(final LoanBuyDownFeeBalance buyDownFeeBalance, + final List adjustmentTransactions, final LocalDate maturityDate, + final LoanBuyDownFeeStrategy buyDownFeeStrategy, final LocalDate tillDate, final MonetaryCurrency currency) { + return switch (buyDownFeeStrategy) { + case EQUAL_AMORTIZATION -> calculateTotalAmortizationTillDateEqualAmortization(buyDownFeeBalance, adjustmentTransactions, + maturityDate, tillDate, currency); + }; + } + + private static Money calculateTotalAmortizationTillDateEqualAmortization(LoanBuyDownFeeBalance balance, + List adjustmentTransactions, LocalDate maturityDate, LocalDate tillDate, MonetaryCurrency currency) { + + BigDecimal unrecognizedAmount = balance.getAmount(); + BigDecimal totalAmortizationAmount = BigDecimal.ZERO; + BigDecimal overAmortizationCorrection = BigDecimal.ZERO; + + List sortedAdjustmentTransactions = adjustmentTransactions.stream() + .sorted(Comparator.comparing(LoanTransaction::getDateOf)).toList(); + LocalDate periodStart = balance.getDate(); + for (LoanTransaction adjustmentTransaction : sortedAdjustmentTransactions) { + long daysUntilMaturity = DateUtils.getDifferenceInDays(periodStart, maturityDate); + long daysOfPeriod = DateUtils.getDifferenceInDays(periodStart, adjustmentTransaction.getDateOf()); + BigDecimal periodAmortization = daysUntilMaturity == 0L ? BigDecimal.ZERO + : unrecognizedAmount.multiply(BigDecimal.valueOf(daysOfPeriod)).divide(BigDecimal.valueOf(daysUntilMaturity), + MoneyHelper.getMathContext()); + + totalAmortizationAmount = totalAmortizationAmount.add(periodAmortization); + unrecognizedAmount = unrecognizedAmount.subtract(periodAmortization).subtract(adjustmentTransaction.getAmount()); + if (MathUtil.isLessThanZero(unrecognizedAmount)) { + overAmortizationCorrection = overAmortizationCorrection.add(unrecognizedAmount); + unrecognizedAmount = BigDecimal.ZERO; + } + periodStart = adjustmentTransaction.getDateOf(); + } + if (periodStart.isBefore(tillDate)) { + long daysUntilMaturity = DateUtils.getDifferenceInDays(periodStart, maturityDate); + long daysOfPeriod = DateUtils.getDifferenceInDays(periodStart, tillDate); + BigDecimal periodAmortization = unrecognizedAmount.multiply(BigDecimal.valueOf(daysOfPeriod)) + .divide(BigDecimal.valueOf(daysUntilMaturity), MoneyHelper.getMathContext()); + totalAmortizationAmount = totalAmortizationAmount.add(periodAmortization); + } else if (balance.getDate().equals(maturityDate)) { + totalAmortizationAmount = totalAmortizationAmount.add(unrecognizedAmount); + } + + return Money.of(currency, totalAmortizationAmount.add(overAmortizationCorrection)); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 4307ea39e..5e627b4d8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -208,4 +208,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0190_buy_down_fee_amortization.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0190_buy_down_fee_amortization.xml new file mode 100644 index 000000000..d9f53156b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0190_buy_down_fee_amortization.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index 9ac262fca..3347b2162 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -110,7 +110,9 @@ public class ExternalEventConfigurationValidationServiceTest { "LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent", "LoanTransactionContractTerminationPostBusinessEvent", "LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent", "LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent", - "LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent"); + "LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent", + "LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent", + "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent"); List tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); @@ -202,7 +204,9 @@ public class ExternalEventConfigurationValidationServiceTest { "LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent", "LoanTransactionContractTerminationPostBusinessEvent", "LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent", "LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent", - "LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent"); + "LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent", + "LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent", + "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent"); List tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanBuyDownFeeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanBuyDownFeeTest.java index 786e20783..57288eaec 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanBuyDownFeeTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanBuyDownFeeTest.java @@ -23,22 +23,28 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Optional; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.PostClientsResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoanProductsResponse; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest; import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.integrationtests.common.BusinessStepHelper; import org.apache.fineract.integrationtests.common.ClientHelper; import org.apache.fineract.integrationtests.common.Utils; import org.apache.fineract.integrationtests.common.accounting.Account; import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -425,4 +431,146 @@ public class LoanBuyDownFeeTest extends BaseLoanIntegrationTest { .transactionAmount(amount).externalId(buyDownFeeExternalId).note("Buy Down Fee Transaction")); return response.getResourceId(); } + + @Test + public void testBuyDownFeeDailyAmortization() { + new BusinessStepHelper().updateSteps("LOAN_CLOSE_OF_BUSINESS", "APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", + "CHECK_LOAN_REPAYMENT_DUE", "CHECK_LOAN_REPAYMENT_OVERDUE", "CHECK_DUE_INSTALLMENTS", "UPDATE_LOAN_ARREARS_AGING", + "ADD_PERIODIC_ACCRUAL_ENTRIES", "ACCRUAL_ACTIVITY_POSTING", "CAPITALIZED_INCOME_AMORTIZATION", "BUY_DOWN_FEE_AMORTIZATION", + "LOAN_INTEREST_RECALCULATION", "EXTERNAL_ASSET_OWNER_TRANSFER"); + + final AtomicReference loanIdRef = new AtomicReference<>(); + final AtomicReference buyDownFeeTransactionIdIdRef = new AtomicReference<>(); + + final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + + final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive() + .enableBuyDownFee(true).buyDownFeeCalculationType(PostLoanProductsRequest.BuyDownFeeCalculationTypeEnum.FLAT) + .buyDownFeeStrategy(PostLoanProductsRequest.BuyDownFeeStrategyEnum.EQUAL_AMORTIZATION) + .buyDownFeeIncomeType(PostLoanProductsRequest.BuyDownFeeIncomeTypeEnum.FEE) + .buyDownExpenseAccountId(buyDownExpenseAccount.getAccountID().longValue()) + .deferredIncomeLiabilityAccountId(deferredIncomeLiabilityAccount.getAccountID().longValue()) + .incomeFromBuyDownAccountId(feeIncomeAccount.getAccountID().longValue())); + + runAt("1 January 2024", () -> { + Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024", + 500.0, 7.0, 3, null); + loanIdRef.set(loanId); + + disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024"); + PostLoansLoanIdTransactionsResponse transactionsResponse = loanTransactionHelper.makeLoanBuyDownFee(loanId, "1 January 2024", + 50.0); + buyDownFeeTransactionIdIdRef.set(transactionsResponse.getResourceId()); + }); + runAt("31 January 2024", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + // summarized amortization + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(0.55, "Accrual", "30 January 2024"), // + transaction(50.0, "Buy Down Fee", "01 January 2024"), // + transaction(16.48, "Buy Down Fee Amortization", "30 January 2024")); + }); + runAt("1 February 2024", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + // daily amortization + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(50.0, "Buy Down Fee", "01 January 2024"), // + transaction(0.55, "Accrual", "30 January 2024"), // + transaction(16.48, "Buy Down Fee Amortization", "30 January 2024"), // + transaction(0.01, "Accrual", "31 January 2024"), // + transaction(0.55, "Buy Down Fee Amortization", "31 January 2024")); + + loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionIdIdRef.get(), "1 February 2024", 10.0); + }); + runAt("2 February 2024", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + // not backdated and not large buy down fee adjustment -> lowered daily amount + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(50.0, "Buy Down Fee", "01 January 2024"), // + transaction(0.55, "Accrual", "30 January 2024"), // + transaction(16.48, "Buy Down Fee Amortization", "30 January 2024"), // + transaction(0.01, "Accrual", "31 January 2024"), // + transaction(0.55, "Buy Down Fee Amortization", "31 January 2024"), // + transaction(10.0, "Buy Down Fee Adjustment", "01 February 2024"), // + transaction(0.02, "Accrual", "01 February 2024"), // + transaction(0.39, "Buy Down Fee Amortization", "01 February 2024")); + + loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionIdIdRef.get(), "10 January 2024", 10.0); + }); + runAt("3 February 2024", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + // backdated buy down fee adjustment -> amortization adjustment + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(50.0, "Buy Down Fee", "01 January 2024"), // + transaction(10.0, "Buy Down Fee Adjustment", "10 January 2024"), // + transaction(0.55, "Accrual", "30 January 2024"), // + transaction(16.48, "Buy Down Fee Amortization", "30 January 2024"), // + transaction(0.01, "Accrual", "31 January 2024"), // + transaction(0.55, "Buy Down Fee Amortization", "31 January 2024"), // + transaction(10.0, "Buy Down Fee Adjustment", "01 February 2024"), // + transaction(0.02, "Accrual", "01 February 2024"), // + transaction(0.39, "Buy Down Fee Amortization", "01 February 2024"), // + transaction(0.02, "Accrual", "02 February 2024"), // + transaction(2.55, "Buy Down Fee Amortization Adjustment", "02 February 2024")); + + loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionIdIdRef.get(), "03 February 2024", 20.0); + }); + runAt("4 February 2024", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + // large (more than remaining unrecognized (15.13)) buy down fee adjustment -> amortization adjustment + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(50.0, "Buy Down Fee", "01 January 2024"), // + transaction(10.0, "Buy Down Fee Adjustment", "10 January 2024"), // + transaction(0.55, "Accrual", "30 January 2024"), // + transaction(16.48, "Buy Down Fee Amortization", "30 January 2024"), // + transaction(0.01, "Accrual", "31 January 2024"), // + transaction(0.55, "Buy Down Fee Amortization", "31 January 2024"), // + transaction(10.0, "Buy Down Fee Adjustment", "01 February 2024"), // + transaction(0.02, "Accrual", "01 February 2024"), // + transaction(0.39, "Buy Down Fee Amortization", "01 February 2024"), // + transaction(0.02, "Accrual", "02 February 2024"), // + transaction(2.55, "Buy Down Fee Amortization Adjustment", "02 February 2024"), // + transaction(20.0, "Buy Down Fee Adjustment", "03 February 2024"), // + transaction(0.02, "Accrual", "03 February 2024"), // + transaction(4.87, "Buy Down Fee Amortization Adjustment", "03 February 2024")); + + // Check journal entries of amortization and amortization adjustment + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + + Optional amortizationTransactionOpt = loanDetails.getTransactions().stream() + .filter(transaction -> LocalDate.of(2024, 2, 1).equals(transaction.getDate()) + && transaction.getType().getBuyDownFeeAmortization()) + .findFirst(); + Assertions.assertTrue(amortizationTransactionOpt.isPresent()); + + verifyTRJournalEntries(amortizationTransactionOpt.get().getId(), // + journalEntry(0.39, feeIncomeAccount, "CREDIT"), // + journalEntry(0.39, deferredIncomeLiabilityAccount, "DEBIT")); + + Optional amortizationAdjustmentTransactionOpt = loanDetails.getTransactions().stream() + .filter(transaction -> LocalDate.of(2024, 2, 3).equals(transaction.getDate()) + && transaction.getType().getBuyDownFeeAmortizationAdjustment()) + .findFirst(); + Assertions.assertTrue(amortizationAdjustmentTransactionOpt.isPresent()); + + verifyTRJournalEntries(amortizationAdjustmentTransactionOpt.get().getId(), // + journalEntry(4.87, deferredIncomeLiabilityAccount, "CREDIT"), // + journalEntry(4.87, feeIncomeAccount, "DEBIT")); + }); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java index a8c4300cb..bc4c92951 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java @@ -646,6 +646,17 @@ public class ExternalEventConfigurationHelper { loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent.put("enabled", false); defaults.add(loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent); + Map loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent = new HashMap<>(); + loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.put("type", "LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent"); + loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.put("enabled", false); + defaults.add(loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent); + + Map loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent = new HashMap<>(); + loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.put("type", + "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent"); + loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.put("enabled", false); + defaults.add(loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent); + return defaults; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java index bec059a9f..96ac70a24 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java @@ -3083,6 +3083,11 @@ public class LoanTransactionHelper { return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction(loanId, request, "buyDownFee")); } + public PostLoansLoanIdTransactionsResponse makeLoanBuyDownFee(Long loanId, String date, double amount) { + return makeLoanBuyDownFee(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate(date) + .locale("en").transactionAmount(amount)); + } + public List getAdvancedPaymentAllocationRules(final Integer loanId) { return Calls.ok(FineractClientHelper.getFineractClient().legacy.getAdvancedPaymentAllocationRulesOfLoan(loanId.longValue())); }