FINERACT-2311: Buy down fee - daily amortization

This commit is contained in:
adam.magyari
2025-07-08 12:38:02 +02:00
committed by Adam Saghy
parent 8a5300ef23
commit b8d6dfa794
24 changed files with 829 additions and 12 deletions
@@ -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<Void> response = businessStepConfigurationApi.updateJobBusinessStepConfig(WORKFLOW_NAME_LOAN_CLOSE_OF_BUSINESS, request)
@@ -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;
}
}
@@ -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;
}
}
@@ -57,5 +57,7 @@ public interface LoanTransactionApiConstants {
capitalizedIncomeAdjustment, //
contractTermination, //
capitalizedIncomeAmortizationAdjustment, //
buyDownFeeAmortization, //
buyDownFeeAmortizationAdjustment, //
}
}
@@ -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() {
@@ -348,6 +348,26 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
};
}
public static LoanTransaction buyDownFeeAmortization(final Loan loan, final Office office, final LocalDate dateOf,
final BigDecimal amount, final ExternalId externalId) {
return switch (loan.getLoanProductRelatedDetail().getBuyDownFeeIncomeType()) {
case FEE -> 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<Long
|| type == LoanTransactionType.WITHDRAW_TRANSFER || type == LoanTransactionType.CHARGE_OFF
|| type == LoanTransactionType.REAMORTIZE || type == LoanTransactionType.REAGE
|| type == LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION || type == LoanTransactionType.CONTRACT_TERMINATION
|| type == LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT);
|| type == LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT
|| type == LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION
|| type == LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT);
}
public void updateOutstandingLoanBalance(BigDecimal outstandingLoanBalance) {
@@ -361,16 +361,27 @@ public interface LoanTransactionRepository extends JpaRepository<LoanTransaction
AND (lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION
OR lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT)
""")
BigDecimal getAmortizedAmount(@Param("loan") Loan loan);
BigDecimal getAmortizedAmountCapitalizedIncome(@Param("loan") Loan loan);
@Query("""
SELECT COALESCE(SUM(CASE WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION THEN lt.amount
WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT THEN -lt.amount
ELSE 0 END), 0) FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND (lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION
OR lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT)
""")
BigDecimal getAmortizedAmountBuyDownFee(@Param("loan") Loan loan);
@Query("""
SELECT lt FROM LoanTransaction lt, LoanTransactionRelation ltr
WHERE lt.reversed = false
AND lt = ltr.fromTransaction
AND ltr.toTransaction = :capitalizedIncome
AND ltr.toTransaction = :transaction
AND ltr.relationType = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.ADJUSTMENT
""")
List<LoanTransaction> findAdjustmentsForCapitalizedIncome(@Param("capitalizedIncome") LoanTransaction capitalizedIncome);
List<LoanTransaction> findAdjustments(@Param("transaction") LoanTransaction transaction);
@Query("""
SELECT lt FROM LoanTransaction lt
@@ -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;
};
}
@@ -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);
}
@@ -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");
};
}
@@ -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<Long, BigDecimal> creditEntry : glAccountBalanceHolder.getCreditBalances().entrySet()) {
if (MathUtil.isGreaterThanZero(creditEntry.getValue())) {
GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(creditEntry.getKey());
this.helper.createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate,
creditEntry.getValue(), glAccount);
}
}
// create debit entries
for (Map.Entry<Long, BigDecimal> debitEntry : glAccountBalanceHolder.getDebitBalances().entrySet()) {
if (MathUtil.isGreaterThanZero(debitEntry.getValue())) {
GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(debitEntry.getKey());
this.helper.createDebitJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate,
debitEntry.getValue(), glAccount);
}
}
}
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<Long, BigDecimal> creditEntry : glAccountBalanceHolder.getCreditBalances().entrySet()) {
if (MathUtil.isGreaterThanZero(creditEntry.getValue())) {
GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(creditEntry.getKey());
this.helper.createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate,
creditEntry.getValue(), glAccount);
}
}
// create debit entries
for (Map.Entry<Long, BigDecimal> debitEntry : glAccountBalanceHolder.getDebitBalances().entrySet()) {
if (MathUtil.isGreaterThanZero(debitEntry.getValue())) {
GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(debitEntry.getKey());
this.helper.createDebitJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate,
debitEntry.getValue(), glAccount);
}
}
}
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<Long, BigDecimal> 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<Long, BigDecimal> 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();
@@ -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";
}
}
@@ -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");
};
@@ -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 {
@@ -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<Long> existingTransactionIds = loanTransactionRepository.findTransactionIdsByLoan(loan);
final List<Long> existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan);
List<LoanBuyDownFeeBalance> 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<LoanTransaction> 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());
};
}
}
@@ -125,7 +125,7 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
BigDecimal totalAmortizationAmount = BigDecimal.ZERO;
for (LoanCapitalizedIncomeBalance balance : balances) {
List<LoanTransaction> adjustments = loanTransactionRepository.findAdjustmentsForCapitalizedIncome(balance.getLoanTransaction());
List<LoanTransaction> 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<LoanTransaction> adjustments = loanTransactionRepository.findAdjustmentsForCapitalizedIncome(balance.getLoanTransaction());
List<LoanTransaction> 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)) {
@@ -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);
}
}
@@ -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<LoanTransaction> 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<LoanTransaction> adjustmentTransactions, LocalDate maturityDate, LocalDate tillDate, MonetaryCurrency currency) {
BigDecimal unrecognizedAmount = balance.getAmount();
BigDecimal totalAmortizationAmount = BigDecimal.ZERO;
BigDecimal overAmortizationCorrection = BigDecimal.ZERO;
List<LoanTransaction> 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));
}
}
@@ -208,4 +208,5 @@
<include file="parts/0187_add_Loan_modified_and_withdrawn_event_configuration.xml" relativeToChangelogFile="true" />
<include file="parts/0188_create_loan_buy_down_fee_balance.xml" relativeToChangelogFile="true" />
<include file="parts/0189_add_loan_buydown_fee_event.xml" relativeToChangelogFile="true" />
<include file="parts/0190_buy_down_fee_amortization.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="1" author="fineract">
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
<insert tableName="r_enum_value">
<column name="enum_name" value="transaction_type_enum"/>
<column name="enum_id" valueNumeric="42"/>
<column name="enum_message_property" value="Buy Down Fee Amortization"/>
<column name="enum_value" value="Buy Down Fee Amortization"/>
<column name="enum_type" valueBoolean="false"/>
</insert>
<insert tableName="r_enum_value">
<column name="enum_name" value="transaction_type_enum"/>
<column name="enum_id" valueNumeric="43"/>
<column name="enum_message_property" value="Buy Down Fee Amortization Adjustment"/>
<column name="enum_value" value="Buy Down Fee Amortization Adjustment"/>
<column name="enum_type" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -110,7 +110,9 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent", "LoanTransactionContractTerminationPostBusinessEvent",
"LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent",
"LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent",
"LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent");
"LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent",
"LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent",
"LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent");
List<FineractPlatformTenant> 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<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -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<Long> loanIdRef = new AtomicReference<>();
final AtomicReference<Long> 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<GetLoansLoanIdTransactions> 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<GetLoansLoanIdTransactions> 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"));
});
}
}
@@ -646,6 +646,17 @@ public class ExternalEventConfigurationHelper {
loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent.put("enabled", false);
defaults.add(loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent);
Map<String, Object> loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent = new HashMap<>();
loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.put("type", "LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent");
loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent.put("enabled", false);
defaults.add(loanBuyDownFeeAmortizationTransactionCreatedBusinessEvent);
Map<String, Object> loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent = new HashMap<>();
loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.put("type",
"LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent");
loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.put("enabled", false);
defaults.add(loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent);
return defaults;
}
@@ -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<AdvancedPaymentData> getAdvancedPaymentAllocationRules(final Integer loanId) {
return Calls.ok(FineractClientHelper.getFineractClient().legacy.getAdvancedPaymentAllocationRulesOfLoan(loanId.longValue()));
}