FINERACT-2232: Capitalized income - Amortization rework

This commit is contained in:
adam.magyari
2025-06-12 23:42:12 +02:00
committed by Adam Saghy
parent e811491da7
commit 76b9cde83c
13 changed files with 480 additions and 475 deletions
@@ -1763,7 +1763,7 @@ public class LoanStepDef extends AbstractStepDef {
final List<GetLoansLoanIdTransactions> transactions = loanDetailsResponse.body().getTransactions();
final Optional<GetLoansLoanIdTransactions> transactionsMatch = transactions.stream()
.filter(t -> formatter.format(t.getDate()).equals(transactionDate) && t.getType().getCapitalizedIncomeAmortization())
.findFirst();
.reduce((one, two) -> two);
if (transactionsMatch.isPresent()) {
testContext().set(TestContextKey.LOAN_CAPITALIZED_INCOME_AMORTIZATION_ID, transactionsMatch.get().getId());
}
File diff suppressed because it is too large Load Diff
@@ -348,4 +348,23 @@ public interface LoanTransactionRepository extends JpaRepository<LoanTransaction
List<LoanTransaction> findNonReversedByLoanAndType(@Param("loan") Loan loan, @Param("type") LoanTransactionType type,
Pageable pageable);
@Query("""
SELECT COALESCE(SUM(CASE WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION THEN lt.amount
WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CAPITALIZED_INCOME_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.CAPITALIZED_INCOME_AMORTIZATION
OR lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT)
""")
BigDecimal getAmortizedAmount(@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.relationType = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.ADJUSTMENT
""")
List<LoanTransaction> findAdjustmentsForCapitalizedIncome(@Param("capitalizedIncome") LoanTransaction capitalizedIncome);
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.loanaccount.service;
import java.time.LocalDate;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.springframework.lang.NonNull;
@@ -26,7 +27,9 @@ public interface LoanCapitalizedIncomeAmortizationProcessingService {
void processCapitalizedIncomeAmortizationOnLoanClosure(@NonNull Loan loan);
void processCapitalizedIncomeAmortizationOnLoanChargeOff(@NonNull Loan loan);
void processCapitalizedIncomeAmortizationOnLoanChargeOff(@NonNull Loan loan, @NonNull LoanTransaction chargeOffTransaction);
void processCapitalizedIncomeAmortizationOnLoanUndoChargeOff(@NonNull LoanTransaction loanTransaction);
void processCapitalizedIncomeAmortizationTillDate(@NonNull Loan loan, @NonNull LocalDate tillDate);
}
@@ -142,7 +142,6 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
// Post journal entries
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
// Accounting uses the original balance, balance update HAS TO HAPPEN AFTER postJournalEntries
LoanCapitalizedIncomeBalance capitalizedIncomeBalance = capitalizedIncomeBalanceRepository.findByLoanIdAndLoanTransactionId(loanId,
capitalizedIncomeTransactionId);
capitalizedIncomeBalance
@@ -21,12 +21,10 @@ package org.apache.fineract.accounting.journalentry.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
@@ -36,19 +34,12 @@ import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.GLAccountBalanceHolder;
import org.apache.fineract.accounting.journalentry.data.LoanDTO;
import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO;
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalance;
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.LoanCapitalizedIncomeBalanceRepository;
import org.springframework.stereotype.Component;
@@ -246,46 +237,10 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
}
// Resolve Debit
GLAccount accountIncomeFromCapitalization = this.helper.getLinkedGLAccountForLoanProduct(loanProductId,
AccrualAccountsForLoan.INCOME_FROM_CAPITALIZATION.getValue(), paymentTypeId);
GLAccount accountDeferredIncome = this.helper.getLinkedGLAccountForLoanProduct(loanProductId,
AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), paymentTypeId);
Optional<LoanTransaction> transactionOpt = loanTransactionRepository.findById(Long.parseLong(transactionId));
if (transactionOpt.isEmpty()) {
throw new PlatformDataIntegrityException("transaction.for.accounting.not.found", "Transaction for accounting not found");
}
Optional<LoanTransactionRelation> relationToOriginalTransactionOpt = transactionOpt.get().getLoanTransactionRelations().stream()//
.filter(rel -> LoanTransactionRelationTypeEnum.REPLAYED.equals(rel.getRelationType()))//
.min(Comparator.comparing(AbstractPersistableCustom::getId));
if (relationToOriginalTransactionOpt.isPresent()) {
// Reverse-replay case
final LoanTransaction originalTransaction = relationToOriginalTransactionOpt.get().getToTransaction();
final List<JournalEntry> originalJournalEntries = this.journalEntryRepository.findJournalEntries(
AccountingProcessorHelper.LOAN_TRANSACTION_IDENTIFIER + originalTransaction.getId(),
PortfolioProductType.LOAN.getValue());
for (JournalEntry originalJournalEntry : originalJournalEntries) {
if (originalJournalEntry.isDebitEntry()) {
if (accountIncomeFromCapitalization.getId().equals(originalJournalEntry.getGlAccount().getId())) {
glAccountBalanceHolder.addToDebit(accountIncomeFromCapitalization, originalJournalEntry.getAmount());
} else if (accountDeferredIncome.getId().equals(originalJournalEntry.getGlAccount().getId())) {
glAccountBalanceHolder.addToDebit(accountDeferredIncome, originalJournalEntry.getAmount());
}
}
}
} else {
LoanCapitalizedIncomeBalance capitalizedIncomeBalance = loanCapitalizedIncomeBalanceRepository
.findBalanceForAdjustment(Long.parseLong(loanTransactionDTO.getTransactionId()));
if (MathUtil.isGreaterThan(transactionAmount, capitalizedIncomeBalance.getUnrecognizedAmount())) {
BigDecimal amortizedAmount = transactionAmount.subtract(capitalizedIncomeBalance.getUnrecognizedAmount());
glAccountBalanceHolder.addToDebit(accountIncomeFromCapitalization, amortizedAmount);
glAccountBalanceHolder.addToDebit(accountDeferredIncome, transactionAmount.subtract(amortizedAmount));
} else {
glAccountBalanceHolder.addToDebit(accountDeferredIncome, transactionAmount);
}
}
glAccountBalanceHolder.addToDebit(accountDeferredIncome, transactionAmount);
}
// create credit entries
@@ -19,30 +19,12 @@
package org.apache.fineract.cob.loan;
import jakarta.transaction.Transactional;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
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.loan.transaction.LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO;
import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.mapper.LoanAccountingBridgeMapper;
import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.util.CapitalizedIncomeAmortizationUtil;
import org.apache.fineract.portfolio.loanaccount.service.LoanCapitalizedIncomeAmortizationProcessingService;
import org.springframework.stereotype.Component;
@Component
@@ -50,54 +32,14 @@ import org.springframework.stereotype.Component;
@Slf4j
public class CapitalizedIncomeAmortizationBusinessStep implements LoanCOBBusinessStep {
private final LoanCapitalizedIncomeBalanceRepository capitalizedIncomeBalanceRepository;
private final BusinessEventNotifierService businessEventNotifierService;
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
private final LoanAccountingBridgeMapper loanAccountingBridgeMapper;
private final LoanTransactionRepository loanTransactionRepository;
private final ExternalIdFactory externalIdFactory;
private final LoanCapitalizedIncomeAmortizationProcessingService loanCapitalizedIncomeAmortizationProcessingService;
@Transactional
@Override
public Loan execute(Loan loan) {
MathContext mc = MoneyHelper.getMathContext();
LocalDate businessDate = DateUtils.getBusinessLocalDate();
List<LoanCapitalizedIncomeBalance> balances = capitalizedIncomeBalanceRepository.findAllByLoanId(loan.getId());
BigDecimal totalAmortizationAmount = BigDecimal.ZERO;
for (LoanCapitalizedIncomeBalance balance : balances) {
if (DateUtils.isAfterInclusive(businessDate, balance.getDate())) {
long daysUntilMaturity = businessDate.until(loan.getMaturityDate(), ChronoUnit.DAYS);
Money dailyAmortization = CapitalizedIncomeAmortizationUtil.calculateDailyAmortization(
loan.getLoanProductRelatedDetail().getCapitalizedIncomeStrategy(), daysUntilMaturity,
balance.getUnrecognizedAmount(), loan.getCurrency().toData());
totalAmortizationAmount = totalAmortizationAmount.add(dailyAmortization.getAmount(), mc);
balance.setUnrecognizedAmount(balance.getUnrecognizedAmount().subtract(dailyAmortization.getAmount(), mc));
}
}
capitalizedIncomeBalanceRepository.saveAll(balances);
if (MathUtil.isGreaterThanZero(totalAmortizationAmount)) {
LoanTransaction transaction = LoanTransaction.capitalizedIncomeAmortization(loan, loan.getOffice(), businessDate,
totalAmortizationAmount, externalIdFactory.create());
loan.addLoanTransaction(transaction);
transaction = loanTransactionRepository.save(transaction);
loanTransactionRepository.flush();
final AccountingBridgeLoanTransactionDTO transactionDTO = loanAccountingBridgeMapper.mapToLoanTransactionData(transaction,
loan.getCurrency().getCode());
final AccountingBridgeDataDTO accountingBridgeData = new AccountingBridgeDataDTO(loan.getId(), loan.getLoanProduct().getId(),
loan.getOfficeId(), loan.getCurrencyCode(), loan.getSummary().getTotalInterestCharged(),
loan.isNoneOrCashOrUpfrontAccrualAccountingEnabledOnLoanProduct(),
loan.isUpfrontAccrualAccountingEnabledOnLoanProduct(), loan.isPeriodicAccrualAccountingEnabledOnLoanProduct(), false,
false, false, null, loan.isClosedWrittenOff(), List.of(transactionDTO));
this.journalEntryWritePlatformService.createJournalEntriesForLoan(accountingBridgeData);
businessEventNotifierService
.notifyPostBusinessEvent(new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(transaction));
}
loanCapitalizedIncomeAmortizationProcessingService.processCapitalizedIncomeAmortizationTillDate(loan, businessDate);
return loan;
}
@@ -21,10 +21,12 @@ 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;
@@ -45,6 +47,7 @@ public class LoanCapitalizedIncomeAmortizationEventService {
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> {
@@ -83,7 +86,21 @@ public class LoanCapitalizedIncomeAmortizationEventService {
final Loan loan = loanTransaction.getLoan();
if (loan.getLoanProductRelatedDetail().isEnableIncomeCapitalization() && loan.isChargedOff() && loanTransaction.isChargeOff()) {
log.debug("Loan charge-off on capitalized income amortization for loan {}", loan.getId());
loanCapitalizedIncomeAmortizationProcessingService.processCapitalizedIncomeAmortizationOnLoanChargeOff(loan);
loanCapitalizedIncomeAmortizationProcessingService.processCapitalizedIncomeAmortizationOnLoanChargeOff(loan,
loanTransaction);
}
}
}
private final class LoanChargeOffPreEventListener implements BusinessEventListener<LoanChargeOffPreBusinessEvent> {
@Override
public void onBusinessEvent(final LoanChargeOffPreBusinessEvent event) {
final Loan loan = event.get();
if (loan.getLoanProductRelatedDetail().isEnableIncomeCapitalization()) {
log.debug("Loan pre charge-off capitalized income amortization for loan {}", loan.getId());
loanCapitalizedIncomeAmortizationProcessingService.processCapitalizedIncomeAmortizationTillDate(loan,
DateUtils.getBusinessLocalDate());
}
}
}
@@ -26,16 +26,24 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
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.LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent;
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.LoanCapitalizedIncomeBalance;
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.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.util.CapitalizedIncomeAmortizationUtil;
import org.jetbrains.annotations.NotNull;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -48,20 +56,29 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanJournalEntryPoster journalEntryPoster;
private final ExternalIdFactory externalIdFactory;
@Override
@Transactional
public void processCapitalizedIncomeAmortizationOnLoanClosure(@NotNull final Loan loan) {
final LocalDate transactionDate = getFinalCapitalizedIncomeAmortizationTransactionDate(loan);
final Optional<LoanTransaction> amortizationTransaction = createCapitalizedIncomeAmortizationTransaction(loan, transactionDate,
false);
amortizationTransaction.ifPresent(loanTransaction -> businessEventNotifierService
.notifyPostBusinessEvent(new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(loanTransaction)));
false, null);
amortizationTransaction.ifPresent(loanTransaction -> {
if (loanTransaction.isCapitalizedIncomeAmortization()) {
businessEventNotifierService
.notifyPostBusinessEvent(new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(loanTransaction));
} else {
businessEventNotifierService.notifyPostBusinessEvent(
new LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent(loanTransaction));
}
});
}
@Override
@Transactional
public void processCapitalizedIncomeAmortizationOnLoanChargeOff(@NotNull final Loan loan) {
public void processCapitalizedIncomeAmortizationOnLoanChargeOff(@NotNull final Loan loan,
@NonNull final LoanTransaction chargeOffTransaction) {
final List<Long> existingTransactionIds = loanTransactionRepository.findTransactionIdsByLoan(loan);
final List<Long> existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan);
@@ -71,39 +88,57 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
}
final Optional<LoanTransaction> amortizationTransaction = createCapitalizedIncomeAmortizationTransaction(loan, transactionDate,
true);
true, chargeOffTransaction);
if (amortizationTransaction.isPresent()) {
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
businessEventNotifierService.notifyPostBusinessEvent(
new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(amortizationTransaction.get()));
if (amortizationTransaction.get().isCapitalizedIncomeAmortization()) {
businessEventNotifierService.notifyPostBusinessEvent(
new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(amortizationTransaction.get()));
} else {
businessEventNotifierService.notifyPostBusinessEvent(
new LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent(amortizationTransaction.get()));
}
}
}
private Optional<LoanTransaction> createCapitalizedIncomeAmortizationTransaction(final Loan loan, final LocalDate transactionDate,
final boolean isChargeOff) {
final boolean isChargeOff, final LoanTransaction chargeOffTransaction) {
ExternalId externalId = ExternalId.empty();
BigDecimal totalUnrecognizedAmount = BigDecimal.ZERO;
if (configurationDomainService.isExternalIdAutoGenerationEnabled()) {
externalId = ExternalId.generate();
}
final List<LoanCapitalizedIncomeBalance> balances = loanCapitalizedIncomeBalanceRepository.findAllByLoanId(loan.getId());
BigDecimal totalAmortizationAmount = BigDecimal.ZERO;
for (LoanCapitalizedIncomeBalance balance : balances) {
final BigDecimal unrecognizedAmount = balance.getUnrecognizedAmount();
totalUnrecognizedAmount = totalUnrecognizedAmount.add(unrecognizedAmount);
List<LoanTransaction> adjustments = loanTransactionRepository.findAdjustmentsForCapitalizedIncome(balance.getLoanTransaction());
LocalDate maturityDate = loan.getMaturityDate() != null ? loan.getMaturityDate() : transactionDate;
final Money amortizationTillDate = CapitalizedIncomeAmortizationUtil.calculateTotalAmortizationTillDate(balance, adjustments,
maturityDate, loan.getLoanProductRelatedDetail().getCapitalizedIncomeStrategy(), maturityDate, loan.getCurrency());
totalAmortizationAmount = totalAmortizationAmount.add(amortizationTillDate.getAmount());
if (isChargeOff) {
balance.setChargedOffAmount(unrecognizedAmount);
balance.setChargedOffAmount(balance.getUnrecognizedAmount());
}
balance.setUnrecognizedAmount(BigDecimal.ZERO);
}
if (MathUtil.isLessThanOrEqualZero(totalUnrecognizedAmount)) {
BigDecimal amortizedAmount = loanTransactionRepository.getAmortizedAmount(loan);
BigDecimal totalUnrecognizedAmount = totalAmortizationAmount.subtract(amortizedAmount);
if (MathUtil.isZero(totalUnrecognizedAmount)) {
return Optional.empty();
}
final LoanTransaction amortizationTransaction = LoanTransaction.capitalizedIncomeAmortization(loan, loan.getOffice(),
transactionDate, totalUnrecognizedAmount, externalId);
final LoanTransaction amortizationTransaction = MathUtil.isGreaterThanZero(totalUnrecognizedAmount)
? LoanTransaction.capitalizedIncomeAmortization(loan, loan.getOffice(), transactionDate, totalUnrecognizedAmount,
externalId)
: LoanTransaction.capitalizedIncomeAmortizationAdjustment(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);
@@ -119,7 +154,10 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
final List<Long> existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan);
loan.getLoanTransactions().stream().filter(LoanTransaction::isCapitalizedIncomeAmortization)
.filter(transaction -> transaction.getTransactionDate().equals(loanTransaction.getTransactionDate()))
.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);
@@ -135,6 +173,57 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
}
@Override
@Transactional
public void processCapitalizedIncomeAmortizationTillDate(@NonNull Loan loan, @NonNull LocalDate tillDate) {
final List<Long> existingTransactionIds = loanTransactionRepository.findTransactionIdsByLoan(loan);
final List<Long> existingReversedTransactionIds = loanTransactionRepository.findReversedTransactionIdsByLoan(loan);
List<LoanCapitalizedIncomeBalance> balances = loanCapitalizedIncomeBalanceRepository.findAllByLoanId(loan.getId());
LocalDate maturityDate = loan.getMaturityDate() != null ? loan.getMaturityDate()
: getFinalCapitalizedIncomeAmortizationTransactionDate(loan);
LocalDate tillDatePlusOne = tillDate.plusDays(1);
if (tillDatePlusOne.isAfter(maturityDate)) {
tillDatePlusOne = maturityDate;
}
Money totalAmortization = Money.zero(loan.getCurrency());
for (LoanCapitalizedIncomeBalance balance : balances) {
List<LoanTransaction> adjustments = loanTransactionRepository.findAdjustmentsForCapitalizedIncome(balance.getLoanTransaction());
Money amortizationTillDate = CapitalizedIncomeAmortizationUtil.calculateTotalAmortizationTillDate(balance, adjustments,
maturityDate, loan.getLoanProductRelatedDetail().getCapitalizedIncomeStrategy(), tillDatePlusOne, loan.getCurrency());
totalAmortization = totalAmortization.add(amortizationTillDate);
balance.setUnrecognizedAmount(balance.getAmount().subtract(MathUtil.nullToZero(balance.getAmountAdjustment()))
.subtract(amortizationTillDate.getAmount()));
}
loanCapitalizedIncomeBalanceRepository.saveAll(balances);
BigDecimal totalAmortized = loanTransactionRepository.getAmortizedAmount(loan);
BigDecimal totalAmortizationAmount = totalAmortization.getAmount().subtract(totalAmortized);
if (!MathUtil.isZero(totalAmortizationAmount)) {
LoanTransaction transaction = MathUtil.isGreaterThanZero(totalAmortizationAmount)
? LoanTransaction.capitalizedIncomeAmortization(loan, loan.getOffice(), tillDate, totalAmortizationAmount,
externalIdFactory.create())
: LoanTransaction.capitalizedIncomeAmortizationAdjustment(loan,
Money.of(loan.getCurrency(), MathUtil.negate(totalAmortizationAmount)), tillDate, externalIdFactory.create());
loan.addLoanTransaction(transaction);
transaction = loanTransactionRepository.save(transaction);
loanTransactionRepository.flush();
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
BusinessEvent<?> event = MathUtil.isGreaterThanZero(totalAmortizationAmount)
? new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(transaction)
: new LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent(transaction);
businessEventNotifierService.notifyPostBusinessEvent(event);
}
}
private LocalDate getFinalCapitalizedIncomeAmortizationTransactionDate(final Loan loan) {
return switch (loan.getStatus()) {
case CLOSED_OBLIGATIONS_MET -> loan.getClosedOnDate();
@@ -37,7 +37,6 @@ import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAdjustTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanBalanceChangedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -156,9 +155,6 @@ public class LoanAdjustmentServiceImpl implements LoanAdjustmentService {
throw new InvalidLoanTransactionTypeException("transaction", "capitalizedIncome.cannot.be.reversed.when.adjusted",
"Capitalized income transaction cannot be reversed when non-reversed adjustment exists for it.");
}
BigDecimal recognizedAmount = capitalizedIncomeBalance.getAmount().subtract(capitalizedIncomeBalance.getUnrecognizedAmount());
capitalizedIncomeAmortizationAdjustmentTransaction = LoanTransaction.capitalizedIncomeAmortizationAdjustment(loan,
Money.of(loan.getCurrency(), recognizedAmount), DateUtils.getBusinessLocalDate(), externalIdFactory.create());
loanCapitalizedIncomeBalanceRepository.delete(capitalizedIncomeBalance);
}
if (transactionToAdjust.isCapitalizedIncomeAdjustment()) {
@@ -213,15 +209,6 @@ public class LoanAdjustmentServiceImpl implements LoanAdjustmentService {
this.loanTransactionRepository.saveAndFlush(newTransactionDetail);
}
if (capitalizedIncomeAmortizationAdjustmentTransaction != null && capitalizedIncomeAmortizationAdjustmentTransaction.isNotZero()) {
LoanTransaction savedCapitalizedIncomeAmortizationAdjustmentTransaction = loanTransactionRepository
.saveAndFlush(capitalizedIncomeAmortizationAdjustmentTransaction);
loan.addLoanTransaction(savedCapitalizedIncomeAmortizationAdjustmentTransaction);
businessEventNotifierService
.notifyPostBusinessEvent(new LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent(
capitalizedIncomeAmortizationAdjustmentTransaction));
}
loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan);
if (StringUtils.isNotBlank(noteText)) {
@@ -19,21 +19,66 @@
package org.apache.fineract.portfolio.loanaccount.util;
import java.math.BigDecimal;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
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.LoanCapitalizedIncomeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeStrategy;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public final class CapitalizedIncomeAmortizationUtil {
private CapitalizedIncomeAmortizationUtil() {}
public static Money calculateDailyAmortization(final LoanCapitalizedIncomeStrategy capitalizedIncomeStrategy,
final long daysTillMaturity, final BigDecimal remainingAmount, final CurrencyData currencyData) {
BigDecimal amortization = switch (capitalizedIncomeStrategy) {
case EQUAL_AMORTIZATION -> remainingAmount.divide(BigDecimal.valueOf(daysTillMaturity), MoneyHelper.getMathContext());
public static Money calculateTotalAmortizationTillDate(final LoanCapitalizedIncomeBalance capitalizedIncomeBalance,
final List<LoanTransaction> adjustmentTransactions, final LocalDate maturityDate,
final LoanCapitalizedIncomeStrategy capitalizedIncomeStrategy, final LocalDate tillDate, final MonetaryCurrency currency) {
return switch (capitalizedIncomeStrategy) {
case EQUAL_AMORTIZATION -> calculateTotalAmortizationTillDateEqualAmortization(capitalizedIncomeBalance, adjustmentTransactions,
maturityDate, tillDate, currency);
};
}
return Money.of(currencyData, amortization);
private static Money calculateTotalAmortizationTillDateEqualAmortization(LoanCapitalizedIncomeBalance 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));
}
}
@@ -1,66 +0,0 @@
/**
* 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.math.MathContext;
import java.math.RoundingMode;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeStrategy;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
class CapitalizedIncomeAmortizationUtilTest {
private static final MockedStatic<MoneyHelper> MONEY_HELPER = Mockito.mockStatic(MoneyHelper.class);
@BeforeAll
public static void init() {
MONEY_HELPER.when(MoneyHelper::getMathContext).thenReturn(new MathContext(19, RoundingMode.HALF_EVEN));
MONEY_HELPER.when(MoneyHelper::getRoundingMode).thenReturn(RoundingMode.HALF_EVEN);
}
@AfterAll
public static void deregister() {
MONEY_HELPER.close();
}
@Test
void calculateDailyAmortizationEqualAmortization() {
CurrencyData currency = new CurrencyData("USD", 2, null);
BigDecimal remaining = BigDecimal.valueOf(50.0);
Money dailyAmortization = CapitalizedIncomeAmortizationUtil
.calculateDailyAmortization(LoanCapitalizedIncomeStrategy.EQUAL_AMORTIZATION, 91L, remaining, currency);
Assertions.assertEquals(BigDecimal.valueOf(0.55), dailyAmortization.getAmount());
BigDecimal remaining2 = BigDecimal.valueOf(8.2);
Money dailyAmortization2 = CapitalizedIncomeAmortizationUtil
.calculateDailyAmortization(LoanCapitalizedIncomeStrategy.EQUAL_AMORTIZATION, 15L, remaining2, currency);
Assertions.assertEquals(BigDecimal.valueOf(0.55), dailyAmortization2.getAmount());
}
}
@@ -232,6 +232,7 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
public void testLoanCapitalizedIncomeAdjustmentWithAmortizationAccounting() {
final AtomicReference<Long> loanIdRef = new AtomicReference<>();
final AtomicReference<Long> capitalizedIncomeIdRef = new AtomicReference<>();
final AtomicReference<Long> capitalizedIncomeAdjustmentTransactionIdRef = new AtomicReference<>();
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
@@ -290,6 +291,11 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
Long capitalizedIncomeAdjustmentTransactionId = loanTransactionHelper
.capitalizedIncomeAdjustment(loanId, capitalizedIncomeIdRef.get(), "3 January 2024", 100.0).getResourceId();
capitalizedIncomeAdjustmentTransactionIdRef.set(capitalizedIncomeAdjustmentTransactionId);
});
runAt("4 January 2024", () -> {
Long loanId = loanIdRef.get();
executeInlineCOB(loanId);
verifyTransactions(loanId, //
transaction(100.0, "Disbursement", "01 January 2024"), //
@@ -297,7 +303,9 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
transaction(1.10, "Capitalized Income Amortization", "01 January 2024"), //
transaction(0.04, "Accrual", "02 January 2024"), //
transaction(1.10, "Capitalized Income Amortization", "02 January 2024"), //
transaction(100.0, "Capitalized Income Adjustment", "03 January 2024") //
transaction(100.0, "Capitalized Income Adjustment", "03 January 2024"), //
transaction(0.04, "Accrual", "03 January 2024"), //
transaction(2.20, "Capitalized Income Amortization Adjustment", "03 January 2024") //
);
verifyJournalEntries(loanId, //
@@ -313,8 +321,11 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
journalEntry(1.10, feeIncomeAccount, "CREDIT"), //
journalEntry(99.92, loansReceivableAccount, "CREDIT"), //
journalEntry(0.08, interestReceivableAccount, "CREDIT"), //
journalEntry(100.0, deferredIncomeLiabilityAccount, "DEBIT"), //
journalEntry(0.04, interestReceivableAccount, "DEBIT"), //
journalEntry(0.04, interestIncomeAccount, "CREDIT"), //
journalEntry(2.20, feeIncomeAccount, "DEBIT"), //
journalEntry(97.80, deferredIncomeLiabilityAccount, "DEBIT") //
journalEntry(2.20, deferredIncomeLiabilityAccount, "CREDIT") //
);
// Reverse-replay
@@ -328,19 +339,16 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
verifyTRJournalEntries(replayedCapitalizedIncomeAdjustmentOpt.get().getId(), //
journalEntry(99.98, loansReceivableAccount, "CREDIT"), //
journalEntry(0.02, interestReceivableAccount, "CREDIT"), //
journalEntry(2.20, feeIncomeAccount, "DEBIT"), //
journalEntry(97.80, deferredIncomeLiabilityAccount, "DEBIT") //
journalEntry(100.0, deferredIncomeLiabilityAccount, "DEBIT") //
);
verifyTRJournalEntries(capitalizedIncomeAdjustmentTransactionId, //
verifyTRJournalEntries(capitalizedIncomeAdjustmentTransactionIdRef.get(), //
journalEntry(99.92, loansReceivableAccount, "CREDIT"), //
journalEntry(0.08, interestReceivableAccount, "CREDIT"), //
journalEntry(2.20, feeIncomeAccount, "DEBIT"), //
journalEntry(97.80, deferredIncomeLiabilityAccount, "DEBIT"), //
journalEntry(100.0, deferredIncomeLiabilityAccount, "DEBIT"), //
journalEntry(99.92, loansReceivableAccount, "DEBIT"), //
journalEntry(0.08, interestReceivableAccount, "DEBIT"), //
journalEntry(2.20, feeIncomeAccount, "CREDIT"), //
journalEntry(97.80, deferredIncomeLiabilityAccount, "CREDIT") //
journalEntry(100.0, deferredIncomeLiabilityAccount, "CREDIT") //
);
});
}
@@ -486,12 +494,18 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
loanTransactionHelper.reverseLoanTransaction(loanId, capitalizedIncomeTransactionIdRef.get(), "3 January 2024");
});
runAt("4 January 2024", () -> {
Long loanId = loanIdRef.get();
executeInlineCOB(loanId);
verifyTransactions(loanId, //
transaction(100.0, "Disbursement", "01 January 2024"), //
transaction(50.0, "Capitalized Income", "01 January 2024"), //
transaction(0.55, "Capitalized Income Amortization", "01 January 2024"), //
transaction(0.03, "Accrual", "02 January 2024"), //
transaction(0.55, "Capitalized Income Amortization", "02 January 2024"), //
transaction(0.01, "Accrual", "03 January 2024"), //
transaction(1.10, "Capitalized Income Amortization Adjustment", "03 January 2024") //
);
@@ -508,6 +522,8 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
journalEntry(0.55, feeIncomeAccount, "CREDIT"), //
journalEntry(50, deferredIncomeLiabilityAccount, "DEBIT"), //
journalEntry(50, loansReceivableAccount, "CREDIT"), //
journalEntry(0.01, interestReceivableAccount, "DEBIT"), //
journalEntry(0.01, interestIncomeAccount, "CREDIT"), //
journalEntry(1.10, feeIncomeAccount, "DEBIT"), //
journalEntry(1.10, deferredIncomeLiabilityAccount, "CREDIT") //
);