FINERACT-2232: Capitalized income - amortization in case of Preclose event

This commit is contained in:
mariiaKraievska
2025-05-13 15:09:37 +02:00
committed by Adam Saghy
parent d1154c59ba
commit e69378637d
27 changed files with 512 additions and 11 deletions
@@ -46,4 +46,5 @@ public class LoanDTO {
@Setter
private boolean markedAsFraud;
private Long chargeOffReasonCodeValue;
private boolean markedAsWrittenOff;
}
@@ -107,6 +107,7 @@ public class AccountingProcessorHelper {
boolean isLoanMarkedAsChargeOff = accountingBridgeData.isChargeOff();
boolean isLoanMarkedAsFraud = accountingBridgeData.isFraud();
final Long chargeOffReasonCodeValue = accountingBridgeData.getChargeOffReasonCodeValue();
final boolean isLoanMarkedAsWrittenOff = accountingBridgeData.isWrittenOff();
final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled();
final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled();
final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled();
@@ -169,7 +170,7 @@ public class AccountingProcessorHelper {
return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled,
periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud,
chargeOffReasonCodeValue);
chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff);
}
public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType,
@@ -127,6 +127,10 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
if (transactionType.isCapitalizedIncome()) {
createJournalEntriesForCapitalizedIncome(loanDTO, loanTransactionDTO, office);
}
// Handle Capitalized Income Amortization
if (transactionType.isCapitalizedIncomeAmortization()) {
createJournalEntriesForCapitalizedIncomeAmortization(loanDTO, loanTransactionDTO, office);
}
}
}
@@ -141,7 +145,7 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
final LocalDate transactionDate = loanTransactionDTO.getTransactionDate();
final BigDecimal principalAmount = loanTransactionDTO.getPrincipal();
final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId();
GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder();
final GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder();
if (MathUtil.isGreaterThanZero(principalAmount)) {
populateCreditDebitMaps(loanProductId, principalAmount, paymentTypeId,
@@ -167,6 +171,53 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
}
}
private void createJournalEntriesForCapitalizedIncomeAmortization(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_CAPITALIZATION;
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 createJournalEntriesForInterestPaymentWaiverOrInterestRefund(LoanDTO loanDTO, LoanTransactionDTO loanTransactionDTO,
Office office) {
final Long loanProductId = loanDTO.getLoanProductId();
@@ -528,6 +528,7 @@ public class LoanTransactionsApiResource {
case interestRefund -> LoanTransactionType.INTEREST_REFUND;
case accrualAdjustment -> LoanTransactionType.ACCRUAL_ADJUSTMENT;
case capitalizedIncome -> LoanTransactionType.CAPITALIZED_INCOME;
case capitalizedIncomeAmortization -> LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION;
default ->
throw new InvalidLoanTransactionTypeException("transaction", transactionTypeParam.name(), "Unknown transaction type");
};
@@ -722,6 +722,10 @@ final class LoansApiResourceSwagger {
public boolean chargeAdjustment;
@Schema(example = "false")
public boolean chargeoff;
@Schema(example = "false")
public boolean capitalizedIncome;
@Schema(example = "false")
public boolean capitalizedIncomeAmortization;
}
static final class GetLoansLoanIdPaymentDetailData {
@@ -440,7 +440,7 @@ public class LoanAccrualsProcessingServiceImpl implements LoanAccrualsProcessing
loan.getOfficeId(), loan.getCurrencyCode(), loan.getSummary().getTotalInterestCharged(),
loan.isNoneOrCashOrUpfrontAccrualAccountingEnabledOnLoanProduct(),
loan.isUpfrontAccrualAccountingEnabledOnLoanProduct(), loan.isPeriodicAccrualAccountingEnabledOnLoanProduct(), false,
false, false, null, newTransactionDTOs);
false, false, null, false, newTransactionDTOs);
this.journalEntryWritePlatformService.createJournalEntriesForLoan(accountingBridgeData);
}
}
@@ -0,0 +1,71 @@
/**
* 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.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.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
@Slf4j
@RequiredArgsConstructor
public class LoanCapitalizedIncomeAmortizationEventService {
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanCapitalizedIncomeAmortizationProcessingService loanCapitalizedIncomeAmortizationProcessingService;
@PostConstruct
public void addListeners() {
businessEventNotifierService.addPreBusinessEventListener(LoanCloseBusinessEvent.class, new LoanCloseListener());
businessEventNotifierService.addPostBusinessEventListener(LoanBalanceChangedBusinessEvent.class, new LoanBalanceChangedListener());
}
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().isEnableIncomeCapitalization()
&& (status.isClosedObligationsMet() || status.isClosedWrittenOff() || status.isOverpaid())) {
log.debug("Loan closure on capitalized income amortization for loan {}", loan.getId());
loanCapitalizedIncomeAmortizationProcessingService.processCapitalizedIncomeAmortizationOnLoanClosure(loan);
}
}
}
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().isEnableIncomeCapitalization()
&& (status.isClosedObligationsMet() || status.isClosedWrittenOff() || status.isOverpaid())) {
log.debug("Loan balance change on capitalized income amortization for loan {}", loan.getId());
loanCapitalizedIncomeAmortizationProcessingService.processCapitalizedIncomeAmortizationOnLoanClosure(loan);
}
}
}
}
@@ -0,0 +1,80 @@
/**
* 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 lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
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.repository.LoanCapitalizedIncomeBalanceRepository;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor
public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements LoanCapitalizedIncomeAmortizationProcessingService {
private final ConfigurationDomainService configurationDomainService;
private final LoanTransactionRepository loanTransactionRepository;
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
private final BusinessEventNotifierService businessEventNotifierService;
@Override
@Transactional
public void processCapitalizedIncomeAmortizationOnLoanClosure(@NotNull final Loan loan) {
final LocalDate finalAmortizationTransactionDate = getFinalCapitalizedIncomeAmortizationTransactionDate(loan);
ExternalId externalId = ExternalId.empty();
BigDecimal totalUnrecognizedAmountCapitalizedIncomeAmount = BigDecimal.ZERO;
final boolean isExternalIdAutoGenerationEnabled = configurationDomainService.isExternalIdAutoGenerationEnabled();
if (isExternalIdAutoGenerationEnabled) {
externalId = ExternalId.generate();
}
for (LoanCapitalizedIncomeBalance balance : loanCapitalizedIncomeBalanceRepository.findAllByLoanId(loan.getId())) {
totalUnrecognizedAmountCapitalizedIncomeAmount = totalUnrecognizedAmountCapitalizedIncomeAmount
.add(balance.getUnrecognizedAmount());
balance.setUnrecognizedAmount(BigDecimal.ZERO);
}
final LoanTransaction finalCapitalizedIncomeAmortization = LoanTransaction.capitalizedIncomeAmortization(loan, loan.getOffice(),
finalAmortizationTransactionDate, totalUnrecognizedAmountCapitalizedIncomeAmount, externalId);
loan.addLoanTransaction(finalCapitalizedIncomeAmortization);
loanTransactionRepository.saveAndFlush(finalCapitalizedIncomeAmortization);
businessEventNotifierService.notifyPostBusinessEvent(
new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(finalCapitalizedIncomeAmortization));
}
private LocalDate getFinalCapitalizedIncomeAmortizationTransactionDate(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());
};
}
}
@@ -119,6 +119,8 @@ 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.LoanCalculateRepaymentPastDueService;
import org.apache.fineract.portfolio.loanaccount.service.LoanCapitalizedIncomeAmortizationEventService;
import org.apache.fineract.portfolio.loanaccount.service.LoanCapitalizedIncomeAmortizationProcessingService;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargeAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargePaidByReadService;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargeReadPlatformService;
@@ -505,4 +507,13 @@ public class LoanAccountConfiguration {
return new LoanAccountServiceImpl(loanRepositoryWrapper, loanTransactionRepository);
}
@Bean
@ConditionalOnMissingBean(LoanCapitalizedIncomeAmortizationEventService.class)
public LoanCapitalizedIncomeAmortizationEventService loanCapitalizedIncomeAmortizationEventService(
BusinessEventNotifierService businessEventNotifierService,
LoanCapitalizedIncomeAmortizationProcessingService loanCapitalizedIncomeAmortizationProcessingService) {
return new LoanCapitalizedIncomeAmortizationEventService(businessEventNotifierService,
loanCapitalizedIncomeAmortizationProcessingService);
}
}
@@ -194,4 +194,5 @@
<include file="parts/0173_user_change_pwd.xml" relativeToChangelogFile="true" />
<include file="parts/0174_loan_product_add_capitalized_income_type.xml" relativeToChangelogFile="true" />
<include file="parts/0175_add_fk_acc_product_mapping.xml" relativeToChangelogFile="true" />
<include file="parts/0176_add_capitalized_income_amortization_transaction.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,38 @@
<?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="LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
<insert tableName="r_enum_value">
<column name="enum_name" value="transaction_type_enum"/>
<column name="enum_id" valueNumeric="36"/>
<column name="enum_message_property" value="Capitalized Income Amortization"/>
<column name="enum_value" value="Capitalized Income Amortization"/>
<column name="enum_type" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -72,7 +72,7 @@ class CreateJournalEntriesForChargeOffLoanTest {
transactionType, new BigDecimal("500.00"), new BigDecimal("500.00"), null, null, null, null, false, Collections.emptyList(),
Collections.emptyList(), false, "", null, null, null, null);
loanDTO = new LoanDTO(1L, 1L, 1L, "USD", false, true, true, List.of(loanTransactionDTO), false, false, chargeOffReasonId);
loanDTO = new LoanDTO(1L, 1L, 1L, "USD", false, true, true, List.of(loanTransactionDTO), false, false, chargeOffReasonId, false);
}
@Test
@@ -105,7 +105,8 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanReAmortizeBusinessEvent", "LoanUndoReAmortizeBusinessEvent", "LoanTransactionInterestPaymentWaiverPreBusinessEvent",
"LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent",
"LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent",
"LoanTransactionInterestRefundPreBusinessEvent", "LoanAccrualAdjustmentTransactionBusinessEvent");
"LoanTransactionInterestRefundPreBusinessEvent", "LoanAccrualAdjustmentTransactionBusinessEvent",
"LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -191,7 +192,8 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanReAmortizeBusinessEvent", "LoanUndoReAmortizeBusinessEvent", "LoanTransactionInterestPaymentWaiverPreBusinessEvent",
"LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent",
"LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent",
"LoanTransactionInterestRefundPreBusinessEvent", "LoanAccrualAdjustmentTransactionBusinessEvent");
"LoanTransactionInterestRefundPreBusinessEvent", "LoanAccrualAdjustmentTransactionBusinessEvent",
"LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));