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
@@ -34,7 +34,8 @@ public enum TransactionType {
CHARGE_ADJUSTMENT("chargeAdjustment"), //
INTEREST_PAYMENT_WAIVER("interestPaymentWaiver"), //
REPAYMENT_AT_DISBURSEMENT("repaymentAtDisbursement"), //
CAPITALIZED_INCOME("capitalizedIncome"),;
CAPITALIZED_INCOME("capitalizedIncome"), //
CAPITALIZED_INCOME_AMORTIZATION("capitalizedIncomeAmortization"),;
public final String value;
@@ -0,0 +1,27 @@
/**
* 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.test.messaging.event.loan.transaction;
public class LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent extends AbstractLoanTransactionEvent {
@Override
public String getEventName() {
return "LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent";
}
}
@@ -139,6 +139,7 @@ import org.apache.fineract.test.messaging.event.loan.transaction.BulkBusinessEve
import org.apache.fineract.test.messaging.event.loan.transaction.LoanAccrualAdjustmentTransactionBusinessEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanAccrualTransactionCreatedBusinessEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanAdjustTransactionBusinessEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanChargeAdjustmentPostBusinessEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanChargeOffEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanChargeOffUndoEvent;
@@ -4116,4 +4117,22 @@ public class LoanStepDef extends AbstractStepDef {
.isEqualTo(ErrorMessageHelper.addCapitalizedIncomeExceedApprovedAmountFailure());
}
@Then("LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent is raised on {string}")
public void checkLoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(final String date) throws IOException {
Response<PostLoansResponse> loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
long loanId = loanCreateResponse.body().getLoanId();
Response<GetLoansLoanIdResponse> loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute();
ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse);
List<GetLoansLoanIdTransactions> transactions = loanDetailsResponse.body().getTransactions();
GetLoansLoanIdTransactions finalAmortizationTransaction = transactions.stream()
.filter(t -> date.equals(FORMATTER.format(t.getDate())) && "Capitalized Income Amortization".equals(t.getType().getValue()))
.findFirst().orElseThrow(
() -> new IllegalStateException(String.format("No Capitalized Income Amortization transaction found on %s", date)));
Long finalAmortizationTransactionId = finalAmortizationTransaction.getId();
eventAssertion.assertEventRaised(LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent.class,
finalAmortizationTransactionId);
}
}
@@ -167,3 +167,105 @@ Feature: Capitalized Income
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | 300.0 | |
| LIABILITY | 145024 | Deferred Capitalized Income | | 300.0 |
@TestRailId:C3646
Scenario: As a user I want to add capitalized income to a progressive loan after disbursement and then make a full repayment - amortization in case of loan close event
When Admin sets the business date to "1 January 2024"
And Admin creates a client with random data
When Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME | 01 January 2024 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 1 | MONTHS | 1 | MONTHS | 1 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
And Admin successfully approves the loan on "1 January 2024" with "1000" amount and expected disbursement date on "1 January 2024"
And Admin successfully disburse the loan on "1 January 2024" with "900" EUR transaction amount
Then Loan status will be "ACTIVE"
When Admin sets the business date to "2 January 2024"
And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "2 January 2024" with "100" EUR transaction amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 900.0 | false |
| 02 January 2024 | Capitalized Income | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false |
Then Loan Transactions tab has a "CAPITALIZED_INCOME" transaction with date "02 January 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | 100.0 | |
| LIABILITY | 145024 | Deferred Capitalized Income | | 100.0 |
When Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "2 January 2024" with 900.17 EUR transaction amount and system-generated Idempotency key
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 900.0 | false |
| 02 January 2024 | Capitalized Income | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false |
| 02 January 2024 | Repayment | 900.17 | 900.0 | 0.17 | 0.0 | 0.0 | 100.0 | false |
| 02 January 2024 | Accrual | 0.17 | 0.0 | 0.17 | 0.0 | 0.0 | 0.0 | false |
| 02 January 2024 | Capitalized Income Amortization | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | false |
Then Loan Transactions tab has a "CAPITALIZED_INCOME_AMORTIZATION" transaction with date "02 January 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| INCOME | 404000 | Interest Income | | 100.0 |
| LIABILITY | 145024 | Deferred Capitalized Income | 100.0 | |
Then LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent is raised on "02 January 2024"
@TestRailId:C3647
Scenario: As a user I want to add capitalized income to a progressive loan after disbursement and then overpay loan - amortization in case of loan close event
When Admin sets the business date to "1 January 2024"
And Admin creates a client with random data
When Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME | 01 January 2024 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 1 | MONTHS | 1 | MONTHS | 1 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
And Admin successfully approves the loan on "1 January 2024" with "1000" amount and expected disbursement date on "1 January 2024"
And Admin successfully disburse the loan on "1 January 2024" with "900" EUR transaction amount
Then Loan status will be "ACTIVE"
When Admin sets the business date to "2 January 2024"
And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "2 January 2024" with "100" EUR transaction amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 900.0 | false |
| 02 January 2024 | Capitalized Income | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false |
Then Loan Transactions tab has a "CAPITALIZED_INCOME" transaction with date "02 January 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | 100.0 | |
| LIABILITY | 145024 | Deferred Capitalized Income | | 100.0 |
When Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "2 January 2024" with 1000 EUR transaction amount and system-generated Idempotency key
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 900.0 | false |
| 02 January 2024 | Capitalized Income | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false |
| 02 January 2024 | Repayment | 1000.0 | 900.0 | 0.17 | 0.0 | 0.0 | 100.0 | false |
| 02 January 2024 | Accrual | 0.17 | 0.0 | 0.17 | 0.0 | 0.0 | 0.0 | false |
| 02 January 2024 | Capitalized Income Amortization | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | false |
Then Loan Transactions tab has a "CAPITALIZED_INCOME_AMORTIZATION" transaction with date "02 January 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| INCOME | 404000 | Interest Income | | 100.0 |
| LIABILITY | 145024 | Deferred Capitalized Income | 100.0 | |
Then LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent is raised on "02 January 2024"
@TestRailId:C3651
Scenario: As a user I want to add capitalized income to a progressive loan after disbursement and then write off loan - amortization in case of loan close event
When Admin sets the business date to "1 January 2024"
And Admin creates a client with random data
When Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME | 01 January 2024 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 1 | MONTHS | 1 | MONTHS | 1 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
And Admin successfully approves the loan on "1 January 2024" with "1000" amount and expected disbursement date on "1 January 2024"
And Admin successfully disburse the loan on "1 January 2024" with "900" EUR transaction amount
Then Loan status will be "ACTIVE"
When Admin sets the business date to "2 January 2024"
And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "2 January 2024" with "100" EUR transaction amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 900.0 | false |
| 02 January 2024 | Capitalized Income | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false |
Then Loan Transactions tab has a "CAPITALIZED_INCOME" transaction with date "02 January 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | 100.0 | |
| LIABILITY | 145024 | Deferred Capitalized Income | | 100.0 |
And Admin does write-off the loan on "02 January 2024"
Then Loan status will be "CLOSED_WRITTEN_OFF"
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 900.0 | 0.0 | 0.0 | 0.0 | 0.0 | 900.0 | false |
| 02 January 2024 | Capitalized Income | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false |
| 02 January 2024 | Close (as written-off) | 905.25 | 900.0 | 5.25 | 0.0 | 0.0 | 100.0 | false |
| 02 January 2024 | Capitalized Income Amortization | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | false |
Then Loan Transactions tab has a "CAPITALIZED_INCOME_AMORTIZATION" transaction with date "02 January 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| EXPENSE | e4 | Written off | | 100.0 |
| LIABILITY | 145024 | Deferred Capitalized Income | 100.0 | |
Then LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent is raised on "02 January 2024"
@@ -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 LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent extends LoanTransactionBusinessEvent {
private static final String TYPE = "LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent";
public LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(LoanTransaction value) {
super(value);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -53,5 +53,6 @@ public interface LoanTransactionApiConstants {
interestRefund, //
accrualAdjustment, //
capitalizedIncome, //
capitalizedIncomeAmortization, //
}
}
@@ -44,6 +44,7 @@ public class AccountingBridgeDataDTO {
private boolean isChargeOff;
private boolean isFraud;
private Long chargeOffReasonCodeValue;
private boolean isWrittenOff;
private List<AccountingBridgeLoanTransactionDTO> newLoanTransactions = new ArrayList<>();
}
@@ -68,6 +68,7 @@ public class LoanTransactionEnumData implements Serializable {
private final boolean interestRefund;
private final boolean accrualAdjustment;
private final boolean capitalizedIncome;
private final boolean capitalizedIncomeAmortization;
public LoanTransactionEnumData(final Long id, final String code, final String value) {
this.id = id;
@@ -105,6 +106,7 @@ public class LoanTransactionEnumData implements Serializable {
this.interestRefund = Long.valueOf(LoanTransactionType.INTEREST_REFUND.getValue()).equals(this.id);
this.accrualAdjustment = Long.valueOf(LoanTransactionType.ACCRUAL_ADJUSTMENT.getValue()).equals(this.id);
this.capitalizedIncome = Long.valueOf(LoanTransactionType.CAPITALIZED_INCOME.getValue()).equals(this.id);
this.capitalizedIncomeAmortization = Long.valueOf(LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION.getValue()).equals(this.id);
}
public boolean isRepaymentType() {
@@ -312,6 +312,16 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
amount.getAmount(), null, null, null, null, false, paymentDetail, externalId);
}
public static LoanTransaction capitalizedIncomeAmortization(final Loan loan, final Office office, final LocalDate dateOf,
final BigDecimal amount, final ExternalId externalId) {
return switch (loan.getLoanProductRelatedDetail().getCapitalizedIncomeType()) {
case FEE -> new LoanTransaction(loan, office, LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION, dateOf, amount, null, null,
amount, null, null, false, null, externalId);
case INTEREST -> new LoanTransaction(loan, office, LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION, dateOf, amount, null,
amount, null, null, null, false, null, externalId);
};
}
public LoanTransaction copyTransactionPropertiesAndMappings() {
LoanTransaction newTransaction = copyTransactionProperties(this);
newTransaction.updateLoanTransactionToRepaymentScheduleMappings(loanTransactionToRepaymentScheduleMappings);
@@ -746,7 +756,8 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
|| type == LoanTransactionType.ACCRUAL_ACTIVITY || type == LoanTransactionType.APPROVE_TRANSFER
|| type == LoanTransactionType.INITIATE_TRANSFER || type == LoanTransactionType.REJECT_TRANSFER
|| type == LoanTransactionType.WITHDRAW_TRANSFER || type == LoanTransactionType.CHARGE_OFF
|| type == LoanTransactionType.REAMORTIZE || type == LoanTransactionType.REAGE);
|| type == LoanTransactionType.REAMORTIZE || type == LoanTransactionType.REAGE
|| type == LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION);
}
public void updateOutstandingLoanBalance(BigDecimal outstandingLoanBalance) {
@@ -68,6 +68,7 @@ public enum LoanTransactionType {
INTEREST_REFUND(33, "loanTransactionType.interestRefund"), //
ACCRUAL_ADJUSTMENT(34, "loanTransactionType.accrualAdjustment"), //
CAPITALIZED_INCOME(35, "loanTransactionType.capitalizedIncome"), //
CAPITALIZED_INCOME_AMORTIZATION(36, "loanTransactionType.capitalizedIncomeAmortization"), //
;
private final Integer value;
@@ -119,6 +120,7 @@ public enum LoanTransactionType {
case 33 -> LoanTransactionType.INTEREST_REFUND;
case 34 -> LoanTransactionType.ACCRUAL_ADJUSTMENT;
case 35 -> LoanTransactionType.CAPITALIZED_INCOME;
case 36 -> LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION;
default -> LoanTransactionType.INVALID;
};
}
@@ -55,12 +55,14 @@ public class LoanAccountingBridgeMapper {
AccountingBridgeDataDTO beforeChargeOff = new AccountingBridgeDataDTO(loan.getId(), loan.productId(), loan.getOfficeId(),
currencyCode, loan.getSummary().getTotalInterestCharged(), loan.isCashBasedAccountingEnabledOnLoanProduct(),
loan.isUpfrontAccrualAccountingEnabledOnLoanProduct(), loan.isPeriodicAccrualAccountingEnabledOnLoanProduct(),
isAccountTransfer, false, loan.isFraud(), loan.fetchChargeOffReasonId(), newLoanTransactionsBeforeChargeOff);
isAccountTransfer, false, loan.isFraud(), loan.fetchChargeOffReasonId(), loan.isClosedWrittenOff(),
newLoanTransactionsBeforeChargeOff);
AccountingBridgeDataDTO afterChargeOff = new AccountingBridgeDataDTO(loan.getId(), loan.productId(), loan.getOfficeId(),
currencyCode, loan.getSummary().getTotalInterestCharged(), loan.isCashBasedAccountingEnabledOnLoanProduct(),
loan.isUpfrontAccrualAccountingEnabledOnLoanProduct(), loan.isPeriodicAccrualAccountingEnabledOnLoanProduct(),
isAccountTransfer, true, loan.isFraud(), loan.fetchChargeOffReasonId(), newLoanTransactionsAfterChargeOff);
isAccountTransfer, true, loan.isFraud(), loan.fetchChargeOffReasonId(), loan.isClosedWrittenOff(),
newLoanTransactionsAfterChargeOff);
List<AccountingBridgeDataDTO> result = new ArrayList<>();
result.add(beforeChargeOff);
@@ -83,7 +85,8 @@ public class LoanAccountingBridgeMapper {
return new AccountingBridgeDataDTO(loan.getId(), loan.productId(), loan.getOfficeId(), currencyCode,
loan.getSummary().getTotalInterestCharged(), loan.isCashBasedAccountingEnabledOnLoanProduct(),
loan.isUpfrontAccrualAccountingEnabledOnLoanProduct(), loan.isPeriodicAccrualAccountingEnabledOnLoanProduct(),
isAccountTransfer, loan.isChargedOff(), loan.isFraud(), loan.fetchChargeOffReasonId(), newLoanTransactions);
isAccountTransfer, loan.isChargedOff(), loan.isFraud(), loan.fetchChargeOffReasonId(), loan.isClosedWrittenOff(),
newLoanTransactions);
}
public AccountingBridgeLoanTransactionDTO mapToLoanTransactionData(final LoanTransaction transaction, final String currencyCode) {
@@ -0,0 +1,27 @@
/**
* 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 org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.springframework.lang.NonNull;
public interface LoanCapitalizedIncomeAmortizationProcessingService {
void processCapitalizedIncomeAmortizationOnLoanClosure(@NonNull Loan loan);
}
@@ -327,6 +327,9 @@ public final class LoanEnumerations {
LoanTransactionType.ACCRUAL_ADJUSTMENT.getCode(), "Accrual Adjustment");
case CAPITALIZED_INCOME -> new LoanTransactionEnumData(LoanTransactionType.CAPITALIZED_INCOME.getValue().longValue(),
LoanTransactionType.CAPITALIZED_INCOME.getCode(), "Capitalized Income");
case CAPITALIZED_INCOME_AMORTIZATION ->
new LoanTransactionEnumData(LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION.getValue().longValue(),
LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION.getCode(), "Capitalized Income Amortization");
};
}
@@ -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));
@@ -583,6 +583,12 @@ public class ExternalEventConfigurationHelper {
loanTransactionInterestRefundPreBusinessEvent.put("enabled", false);
defaults.add(loanTransactionInterestRefundPreBusinessEvent);
Map<String, Object> loanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent = new HashMap<>();
loanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent.put("type",
"LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent");
loanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent.put("enabled", false);
defaults.add(loanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent);
return defaults;
}