FINERACT-2311: Buy down fee - final amortization

- early repayment
- write off
- charge-off
This commit is contained in:
adam.magyari
2025-07-15 10:46:55 +02:00
committed by Adam Saghy
parent 35c51278f9
commit 880580f736
8 changed files with 319 additions and 15 deletions
@@ -0,0 +1,121 @@
/**
* 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 jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.event.business.BusinessEventListener;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanBalanceChangedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanCloseBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanChargeOffPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanChargeOffPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanUndoChargeOffBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
@Slf4j
@RequiredArgsConstructor
public class LoanBuyDownFeeAmortizationEventService {
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanBuyDownFeeAmortizationProcessingService loanBuyDownFeeAmortizationProcessingService;
@PostConstruct
public void addListeners() {
businessEventNotifierService.addPreBusinessEventListener(LoanCloseBusinessEvent.class, new LoanCloseListener());
businessEventNotifierService.addPostBusinessEventListener(LoanBalanceChangedBusinessEvent.class, new LoanBalanceChangedListener());
businessEventNotifierService.addPostBusinessEventListener(LoanChargeOffPostBusinessEvent.class, new LoanChargeOffEventListener());
businessEventNotifierService.addPostBusinessEventListener(LoanUndoChargeOffBusinessEvent.class,
new LoanUndoChargeOffEventListener());
businessEventNotifierService.addPreBusinessEventListener(LoanChargeOffPreBusinessEvent.class, new LoanChargeOffPreEventListener());
}
private final class LoanCloseListener implements BusinessEventListener<LoanCloseBusinessEvent> {
@Override
public void onBusinessEvent(final LoanCloseBusinessEvent event) {
final Loan loan = event.get();
final LoanStatus status = loan.getStatus();
if (loan.getLoanProductRelatedDetail().isEnableBuyDownFee()
&& (status.isClosedObligationsMet() || status.isClosedWrittenOff() || status.isOverpaid())) {
log.debug("Loan closure on buy down fee amortization for loan {}", loan.getId());
loanBuyDownFeeAmortizationProcessingService.processBuyDownFeeAmortizationOnLoanClosure(loan, false);
}
}
}
private final class LoanBalanceChangedListener implements BusinessEventListener<LoanBalanceChangedBusinessEvent> {
@Override
public void onBusinessEvent(final LoanBalanceChangedBusinessEvent event) {
final Loan loan = event.get();
final LoanStatus status = loan.getStatus();
if (loan.getLoanProductRelatedDetail().isEnableBuyDownFee()
&& (status.isClosedObligationsMet() || status.isClosedWrittenOff() || status.isOverpaid())) {
log.debug("Loan balance change on buy down fee amortization for loan {}", loan.getId());
loanBuyDownFeeAmortizationProcessingService.processBuyDownFeeAmortizationOnLoanClosure(loan, true);
}
}
}
private final class LoanChargeOffEventListener implements BusinessEventListener<LoanChargeOffPostBusinessEvent> {
@Override
public void onBusinessEvent(final LoanChargeOffPostBusinessEvent event) {
final LoanTransaction loanTransaction = event.get();
final Loan loan = loanTransaction.getLoan();
if (loan.getLoanProductRelatedDetail().isEnableBuyDownFee() && loan.isChargedOff() && loanTransaction.isChargeOff()) {
log.debug("Loan charge-off on buy down fee amortization for loan {}", loan.getId());
loanBuyDownFeeAmortizationProcessingService.processBuyDownFeeAmortizationOnLoanChargeOff(loan, loanTransaction);
}
}
}
private final class LoanChargeOffPreEventListener implements BusinessEventListener<LoanChargeOffPreBusinessEvent> {
@Override
public void onBusinessEvent(final LoanChargeOffPreBusinessEvent event) {
final Loan loan = event.get();
if (loan.getLoanProductRelatedDetail().isEnableBuyDownFee()) {
log.debug("Loan pre charge-off buy down fee amortization for loan {}", loan.getId());
loanBuyDownFeeAmortizationProcessingService.processBuyDownFeeAmortizationTillDate(loan, DateUtils.getBusinessLocalDate(),
true);
}
}
}
private final class LoanUndoChargeOffEventListener implements BusinessEventListener<LoanUndoChargeOffBusinessEvent> {
@Override
public void onBusinessEvent(final LoanUndoChargeOffBusinessEvent event) {
final LoanTransaction loanTransaction = event.get();
final Loan loan = loanTransaction.getLoan();
final LoanStatus status = loan.getStatus();
if (loan.getLoanProductRelatedDetail().isEnableBuyDownFee() && loanTransaction.getTypeOf().isChargeOff()
&& !(loan.isChargedOff() || status.isClosedObligationsMet() || status.isClosedWrittenOff() || status.isOverpaid())) {
log.debug("Loan undo charge-off on buy down fee amortization for loan {}", loan.getId());
loanBuyDownFeeAmortizationProcessingService.processBuyDownFeeAmortizationOnLoanUndoChargeOff(loanTransaction);
}
}
}
}
@@ -20,11 +20,16 @@ package org.apache.fineract.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.service.DateUtils;
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.LoanAdjustTransactionBusinessEvent;
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;
@@ -32,6 +37,8 @@ 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.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.repository.LoanBuyDownFeeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.util.BuyDownFeeAmortizationUtil;
@@ -102,6 +109,127 @@ public class LoanBuyDownFeeAmortizationProcessingServiceImpl implements LoanBuyD
}
}
@Override
@Transactional
public void processBuyDownFeeAmortizationOnLoanClosure(@NonNull final Loan loan, final boolean addJournal) {
List<Long> existingTransactionIds = Collections.emptyList();
List<Long> existingReversedTransactionIds = Collections.emptyList();
if (addJournal) {
existingTransactionIds = loanTransactionRepository.findTransactionIdsByLoan(loan);
existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan);
}
final LocalDate transactionDate = getFinalBuyDownFeeAmortizationTransactionDate(loan);
final Optional<LoanTransaction> amortizationTransaction = createBuyDownFeeAmortizationTransaction(loan, transactionDate, false,
null);
amortizationTransaction.ifPresent(loanTransaction -> {
if (loanTransaction.isBuyDownFeeAmortization()) {
businessEventNotifierService
.notifyPostBusinessEvent(new LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent(loanTransaction));
} else {
businessEventNotifierService
.notifyPostBusinessEvent(new LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent(loanTransaction));
}
});
if (addJournal) {
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
}
}
@Override
@Transactional
public void processBuyDownFeeAmortizationOnLoanChargeOff(@NonNull final Loan loan,
@NonNull final LoanTransaction chargeOffTransaction) {
final List<Long> existingTransactionIds = loanTransactionRepository.findTransactionIdsByLoan(loan);
final List<Long> existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan);
LocalDate transactionDate = loan.getChargedOffOnDate();
if (transactionDate == null) {
transactionDate = DateUtils.getBusinessLocalDate();
}
final Optional<LoanTransaction> amortizationTransaction = createBuyDownFeeAmortizationTransaction(loan, transactionDate, true,
chargeOffTransaction);
if (amortizationTransaction.isPresent()) {
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
if (amortizationTransaction.get().isBuyDownFeeAmortization()) {
businessEventNotifierService.notifyPostBusinessEvent(
new LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent(amortizationTransaction.get()));
} else {
businessEventNotifierService.notifyPostBusinessEvent(
new LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent(amortizationTransaction.get()));
}
}
}
@Override
@Transactional
public void processBuyDownFeeAmortizationOnLoanUndoChargeOff(@NonNull final LoanTransaction loanTransaction) {
final Loan loan = loanTransaction.getLoan();
final List<Long> existingTransactionIds = loanTransactionRepository.findTransactionIdsByLoan(loan);
final List<Long> existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan);
loan.getLoanTransactions().stream().filter(LoanTransaction::isBuyDownFeeAmortization)
.filter(transaction -> transaction.getTransactionDate().equals(loanTransaction.getTransactionDate())
&& transaction.getLoanTransactionRelations().stream()
.anyMatch(rel -> LoanTransactionRelationTypeEnum.RELATED.equals(rel.getRelationType())
&& rel.getToTransaction().equals(loanTransaction)))
.forEach(transaction -> {
transaction.reverse();
final LoanAdjustTransactionBusinessEvent.Data data = new LoanAdjustTransactionBusinessEvent.Data(transaction);
businessEventNotifierService.notifyPostBusinessEvent(new LoanAdjustTransactionBusinessEvent(data));
});
for (LoanBuyDownFeeBalance balance : loanBuyDownFeeBalanceRepository.findAllByLoanId(loan.getId())) {
balance.setUnrecognizedAmount(balance.getChargedOffAmount());
balance.setChargedOffAmount(BigDecimal.ZERO);
}
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
}
private Optional<LoanTransaction> createBuyDownFeeAmortizationTransaction(final Loan loan, final LocalDate transactionDate,
final boolean isChargeOff, final LoanTransaction chargeOffTransaction) {
ExternalId externalId = externalIdFactory.create();
final List<LoanBuyDownFeeBalance> balances = loanBuyDownFeeBalanceRepository.findAllByLoanId(loan.getId());
BigDecimal totalAmortizationAmount = BigDecimal.ZERO;
for (LoanBuyDownFeeBalance balance : balances) {
List<LoanTransaction> adjustments = loanTransactionRepository.findAdjustments(balance.getLoanTransaction());
LocalDate maturityDate = loan.getMaturityDate() != null ? loan.getMaturityDate() : transactionDate;
final Money amortizationTillDate = BuyDownFeeAmortizationUtil.calculateTotalAmortizationTillDate(balance, adjustments,
maturityDate, loan.getLoanProductRelatedDetail().getBuyDownFeeStrategy(), maturityDate, loan.getCurrency());
totalAmortizationAmount = totalAmortizationAmount.add(amortizationTillDate.getAmount());
if (isChargeOff) {
balance.setChargedOffAmount(balance.getUnrecognizedAmount());
}
balance.setUnrecognizedAmount(BigDecimal.ZERO);
}
BigDecimal amortizedAmount = loanTransactionRepository.getAmortizedAmountBuyDownFee(loan);
BigDecimal totalUnrecognizedAmount = totalAmortizationAmount.subtract(amortizedAmount);
if (MathUtil.isZero(totalUnrecognizedAmount)) {
return Optional.empty();
}
final LoanTransaction amortizationTransaction = MathUtil.isGreaterThanZero(totalUnrecognizedAmount)
? LoanTransaction.buyDownFeeAmortization(loan, loan.getOffice(), transactionDate, totalUnrecognizedAmount, externalId)
: LoanTransaction.buyDownFeeAmortizationAdjustment(loan,
Money.of(loan.getCurrency(), MathUtil.negate(totalUnrecognizedAmount)), transactionDate, externalId);
if (isChargeOff) {
amortizationTransaction.getLoanTransactionRelations().add(LoanTransactionRelation.linkToTransaction(amortizationTransaction,
chargeOffTransaction, LoanTransactionRelationTypeEnum.RELATED));
}
loan.addLoanTransaction(amortizationTransaction);
loanTransactionRepository.saveAndFlush(amortizationTransaction);
return Optional.of(amortizationTransaction);
}
private LocalDate getFinalBuyDownFeeAmortizationTransactionDate(final Loan loan) {
return switch (loan.getStatus()) {
case CLOSED_OBLIGATIONS_MET -> loan.getClosedOnDate();
@@ -100,7 +100,7 @@ public class LoanCapitalizedIncomeAmortizationEventService {
if (loan.getLoanProductRelatedDetail().isEnableIncomeCapitalization()) {
log.debug("Loan pre charge-off capitalized income amortization for loan {}", loan.getId());
loanCapitalizedIncomeAmortizationProcessingService.processCapitalizedIncomeAmortizationTillDate(loan,
DateUtils.getBusinessLocalDate(), false);
DateUtils.getBusinessLocalDate(), true);
}
}
}
@@ -123,6 +123,7 @@ 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.LoanBuyDownFeeAmortizationEventService;
import org.apache.fineract.portfolio.loanaccount.service.LoanBuyDownFeeAmortizationProcessingService;
import org.apache.fineract.portfolio.loanaccount.service.LoanBuyDownFeeAmortizationProcessingServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.LoanCalculateRepaymentPastDueService;
@@ -573,4 +574,12 @@ public class LoanAccountConfiguration {
return new LoanBuyDownFeeAmortizationProcessingServiceImpl(loanTransactionRepository, loanBuyDownFeeBalanceRepository,
businessEventNotifierService, journalEntryPoster, externalIdFactory);
}
@Bean
@ConditionalOnMissingBean(LoanBuyDownFeeAmortizationEventService.class)
public LoanBuyDownFeeAmortizationEventService loanBuyDownFeeAmortizationEventService(
BusinessEventNotifierService businessEventNotifierService,
LoanBuyDownFeeAmortizationProcessingService loanBuyDownFeeAmortizationProcessingService) {
return new LoanBuyDownFeeAmortizationEventService(businessEventNotifierService, loanBuyDownFeeAmortizationProcessingService);
}
}