FINERACT-2232: add income capitalization principal handling and repayment schedule calculations on loans

This commit is contained in:
mark.vituska
2025-05-27 18:35:39 +02:00
committed by Adam Saghy
parent c89ec910d8
commit aac62a92bb
38 changed files with 686 additions and 148 deletions
@@ -43,8 +43,8 @@ public class AcmeNoteWritePlatformService implements NoteWritePlatformService, I
}
@Override
public void createLoanNote(Long loanId, String note) {
throw new UnsupportedOperationException("createLoanNote() is not yet implemented.");
public void createLoanTransactionNote(Long loanTransactionId, String note) {
throw new UnsupportedOperationException("createLoanTransactionNote() is not yet implemented.");
}
@Override
@@ -458,6 +458,30 @@
"null",
"bigdecimal"
]
},
{
"default": null,
"name": "totalPrincipal",
"type": [
"null",
"bigdecimal"
]
},
{
"default": null,
"name": "totalCapitalizedIncome",
"type": [
"null",
"bigdecimal"
]
},
{
"default": null,
"name": "totalCapitalizedIncomeAdjustment",
"type": [
"null",
"bigdecimal"
]
}
]
}
@@ -310,6 +310,12 @@ public final class DateUtils {
return isBeforeBusinessDate(localDate);
}
public static boolean isDateInRangeFromInclusiveToExclusive(final LocalDate fromInclusive, final LocalDate upToNotInclusive,
final LocalDate target) {
return (DateUtils.isEqual(target, fromInclusive) || DateUtils.isAfter(target, fromInclusive))
&& DateUtils.isBefore(target, upToNotInclusive);
}
public static int compare(LocalDate first, LocalDate second) {
return compare(first, second, true);
}
@@ -26,7 +26,7 @@ public interface NoteWritePlatformService {
CommandProcessingResult createNote(JsonCommand command);
void createLoanNote(Long loanId, String note);
void createLoanTransactionNote(Long loanTransactionId, String note);
CommandProcessingResult updateNote(JsonCommand command);
@@ -188,12 +188,12 @@ Feature: Capitalized Income
| 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
When Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "2 January 2024" with 1000.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 | Repayment | 1000.17| 1000.0 | 0.17 | 0.0 | 0.0 | 0.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:
@@ -222,12 +222,12 @@ Feature: Capitalized Income
| 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
When Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "2 January 2024" with 1100 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 | Repayment | 1100.0 | 1000.0 | 0.17 | 0.0 | 0.0 | 0.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:
@@ -262,7 +262,7 @@ Feature: Capitalized Income
| 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 | Close (as written-off) | 1005.81| 1000.0 | 5.81 | 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 |
@@ -555,16 +555,16 @@ Feature: Capitalized Income
And Admin does charge-off the loan with reason "DELINQUENT" on "03 February 2024"
Then Loan Transactions tab has a "CHARGE_OFF" transaction with date "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | | 900.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.0 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 900.0 | |
| INCOME | 404001 | Interest Income Charge Off | 4.0 | |
| ASSET | 112601 | Loans Receivable | | 1000.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.58 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 1000.0| |
| INCOME | 404001 | Interest Income Charge Off | 4.58 | |
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 |
| 03 February 2024 | Accrual | 4.0 | 0.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 904.0 | 900.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Accrual | 4.58 | 0.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 1004.58| 1000.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 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 "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
@@ -597,16 +597,16 @@ Feature: Capitalized Income
And Admin does charge-off the loan on "03 February 2024"
Then Loan Transactions tab has a "CHARGE_OFF" transaction with date "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | | 900.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.0 |
| EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 900.0 | |
| INCOME | 404001 | Interest Income Charge Off | 4.0 | |
| ASSET | 112601 | Loans Receivable | | 1000.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.58 |
| EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | 1000.0| |
| INCOME | 404001 | Interest Income Charge Off | 4.58 | |
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 |
| 03 February 2024 | Accrual | 4.0 | 0.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 904.0 | 900.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Accrual | 4.58 | 0.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 1004.58| 1000.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 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 "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
@@ -639,16 +639,16 @@ Feature: Capitalized Income
And Admin does charge-off the loan with reason "DELINQUENT" on "03 February 2024"
Then Loan Transactions tab has a "CHARGE_OFF" transaction with date "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | | 900.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.0 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 900.0 | |
| INCOME | 404001 | Interest Income Charge Off | 4.0 | |
| ASSET | 112601 | Loans Receivable | | 1000.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.58 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 1000.0| |
| INCOME | 404001 | Interest Income Charge Off | 4.58 | |
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 |
| 03 February 2024 | Accrual | 4.0 | 0.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 904.0 | 900.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Accrual | 4.58 | 0.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 1004.58| 1000.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 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 "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
@@ -681,16 +681,16 @@ Feature: Capitalized Income
And Admin does charge-off the loan with reason "DELINQUENT" on "03 February 2024"
Then Loan Transactions tab has a "CHARGE_OFF" transaction with date "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | | 900.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.0 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 900.0 | |
| INCOME | 404001 | Interest Income Charge Off | 4.0 | |
| ASSET | 112601 | Loans Receivable | | 1000.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.58 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 1000.0| |
| INCOME | 404001 | Interest Income Charge Off | 4.58 | |
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 |
| 03 February 2024 | Accrual | 4.0 | 0.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 904.0 | 900.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Accrual | 4.58 | 0.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 1004.58| 1000.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 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 "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
@@ -700,20 +700,20 @@ Feature: Capitalized Income
Then Admin does a charge-off undo the loan
Then Loan Transactions tab has a "CHARGE_OFF" transaction with date "03 February 2024" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | | 900.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.0 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 900.0 | |
| INCOME | 404001 | Interest Income Charge Off | 4.0 | |
| ASSET | 112601 | Loans Receivable | 900.0 | |
| ASSET | 112603 | Interest/Fee Receivable | 4.0 | |
| EXPENSE | 744007 | Credit Loss/Bad Debt | | 900.0 |
| INCOME | 404001 | Interest Income Charge Off | | 4.0 |
| ASSET | 112601 | Loans Receivable | | 1000.0 |
| ASSET | 112603 | Interest/Fee Receivable | | 4.58 |
| EXPENSE | 744007 | Credit Loss/Bad Debt | 1000.0| |
| INCOME | 404001 | Interest Income Charge Off | 4.58 | |
| ASSET | 112601 | Loans Receivable | 1000.0| |
| ASSET | 112603 | Interest/Fee Receivable | 4.58 | |
| EXPENSE | 744007 | Credit Loss/Bad Debt | | 1000.0 |
| INCOME | 404001 | Interest Income Charge Off | | 4.58 |
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 |
| 03 February 2024 | Accrual | 4.0 | 0.0 | 4.0 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 904.0 | 900.0 | 4.0 | 0.0 | 0.0 | 0.0 | true |
| 03 February 2024 | Accrual | 4.58 | 0.0 | 4.58 | 0.0 | 0.0 | 0.0 | false |
| 03 February 2024 | Charge-off | 1004.58| 1000.0 | 4.58 | 0.0 | 0.0 | 0.0 | true |
Then Reversed loan capitalized income amortization transaction has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| EXPENSE | 744007 | Credit Loss/Bad Debt | | 100.0 |
@@ -30,7 +30,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanSchedul
*/
@RequiredArgsConstructor
@Getter
public final class DisbursementData implements Comparable<DisbursementData> {
public final class DisbursementData implements LoanPrincipalRelatedDataHolder, Comparable<DisbursementData> {
private final Long id;
private final LocalDate expectedDisbursementDate;
@@ -102,8 +102,7 @@ public final class DisbursementData implements Comparable<DisbursementData> {
private boolean occursOnDayFromAndIncludingAndUpTo(final LocalDate fromInclusive, final LocalDate upToNotInclusive,
final LocalDate target) {
return (DateUtils.isEqual(target, fromInclusive) || DateUtils.isAfter(target, fromInclusive))
&& DateUtils.isBefore(target, upToNotInclusive);
return DateUtils.isDateInRangeFromInclusiveToExclusive(fromInclusive, upToNotInclusive, target);
}
public BigDecimal getWaivedChargeAmount() {
@@ -34,6 +34,9 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData;
public class LoanSummaryData {
private final CurrencyData currency;
private final BigDecimal totalPrincipal;
private final BigDecimal totalCapitalizedIncome;
private final BigDecimal totalCapitalizedIncomeAdjustment;
private final BigDecimal principalDisbursed;
private final BigDecimal principalAdjustments;
private final BigDecimal principalPaid;
@@ -38,6 +38,15 @@ import org.apache.fineract.organisation.monetary.domain.Money;
public class LoanSummary {
// derived totals fields
@Column(name = "total_principal_derived", scale = 6, precision = 19)
private BigDecimal totalPrincipal;
@Column(name = "capitalized_income_derived", scale = 6, precision = 19)
private BigDecimal totalCapitalizedIncome;
@Column(name = "capitalized_income_adjustment_derived", scale = 6, precision = 19)
private BigDecimal totalCapitalizedIncomeAdjustment;
@Column(name = "principal_disbursed_derived", scale = 6, precision = 19)
private BigDecimal totalPrincipalDisbursed;
@@ -166,6 +175,9 @@ public class LoanSummary {
this.totalPenaltyChargesWaived = BigDecimal.ZERO;
this.totalPenaltyChargesWrittenOff = BigDecimal.ZERO;
this.totalPrincipalAdjustments = BigDecimal.ZERO;
this.totalPrincipal = BigDecimal.ZERO;
this.totalCapitalizedIncome = BigDecimal.ZERO;
this.totalCapitalizedIncomeAdjustment = BigDecimal.ZERO;
this.totalPrincipalDisbursed = BigDecimal.ZERO;
this.totalPrincipalOutstanding = BigDecimal.ZERO;
this.totalPrincipalRepaid = BigDecimal.ZERO;
@@ -176,17 +188,20 @@ public class LoanSummary {
}
public void updateSummary(final MonetaryCurrency currency, final Money principal,
final List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments, Set<LoanCharge> charges) {
final List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments, Set<LoanCharge> charges, Money capitalizedIncome,
Money capitalizedIncomeAdjustment) {
this.totalPrincipalDisbursed = principal.getAmount();
this.totalCapitalizedIncome = capitalizedIncome.getAmount();
this.totalCapitalizedIncomeAdjustment = capitalizedIncomeAdjustment.getAmount();
this.totalPrincipal = principal.plus(capitalizedIncome).getAmount();
this.totalPrincipalAdjustments = calculateTotalPrincipalAdjusted(repaymentScheduleInstallments, currency).getAmount();
this.totalFeeAdjustments = calculateTotalFeeAdjusted(repaymentScheduleInstallments, currency).getAmount();
this.totalPenaltyAdjustments = calculateTotalPenaltyAdjusted(repaymentScheduleInstallments, currency).getAmount();
this.totalPrincipalRepaid = calculateTotalPrincipalRepaid(repaymentScheduleInstallments, currency).getAmount();
this.totalPrincipalWrittenOff = calculateTotalPrincipalWrittenOff(repaymentScheduleInstallments, currency).getAmount();
this.totalPrincipalOutstanding = principal.plus(this.totalPrincipalAdjustments).minus(this.totalPrincipalRepaid)
.minus(this.totalPrincipalWrittenOff).getAmount();
this.totalPrincipalOutstanding = principal.plus(capitalizedIncome).plus(this.totalPrincipalAdjustments)
.minus(this.totalPrincipalRepaid).minus(this.totalPrincipalWrittenOff).getAmount();
final Money totalInterestCharged = calculateTotalInterestCharged(repaymentScheduleInstallments, currency);
this.totalInterestCharged = totalInterestCharged.getAmount();
@@ -225,7 +240,7 @@ public class LoanSummary {
this.totalPenaltyChargesOutstanding = totalPenaltyChargesCharged.minus(this.totalPenaltyChargesRepaid)
.minus(this.totalPenaltyChargesWaived).minus(this.totalPenaltyChargesWrittenOff).getAmount();
final Money totalExpectedRepayment = Money.of(currency, this.totalPrincipalDisbursed).plus(this.totalInterestCharged)
final Money totalExpectedRepayment = Money.of(currency, this.totalPrincipal).plus(this.totalInterestCharged)
.plus(this.totalFeeChargesCharged).plus(this.totalPenaltyChargesCharged);
this.totalExpectedRepayment = totalExpectedRepayment.getAmount();
@@ -0,0 +1,53 @@
/**
* 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.domain;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.apache.fineract.portfolio.loanaccount.data.LoanPrincipalRelatedDataHolder;
/**
* Immutable data object representing a subset of loan transaction data.
*/
@Getter
@EqualsAndHashCode
public class LoanTransactionRepaymentPeriodData implements LoanPrincipalRelatedDataHolder, Serializable {
private final Long transactionId;
private final Long loanId;
private final LocalDate date;
private final boolean reversed;
private final BigDecimal amount;
private final BigDecimal unrecognizedAmount;
private final BigDecimal feeChargesPortion;
public LoanTransactionRepaymentPeriodData(Long transactionId, Long loanId, LocalDate date, boolean reversed, BigDecimal amount,
BigDecimal unrecognizedAmount, BigDecimal feeChargesPortion) {
this.transactionId = transactionId;
this.loanId = loanId;
this.date = date;
this.reversed = reversed;
this.amount = amount;
this.unrecognizedAmount = unrecognizedAmount;
this.feeChargesPortion = feeChargesPortion;
}
}
@@ -104,7 +104,10 @@ public final class LoanRefundValidator {
if (loan.getLoanProduct().isMultiDisburseLoan() && adjustedTransaction == null) {
final BigDecimal totalDisbursed = loan.getDisbursedAmount();
final BigDecimal totalPrincipalAdjusted = loan.getSummary().getTotalPrincipalAdjustments();
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted);
final BigDecimal totalCapitalizedIncome = loan.getLoanTransactions(LoanTransaction::isCapitalizedIncome).stream()
.filter(LoanTransaction::isNotReversed).map(LoanTransaction::getPrincipalPortion)
.reduce(BigDecimal.ZERO, BigDecimal::add);
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted).add(totalCapitalizedIncome);
if (totalPrincipalCredited.compareTo(loan.getSummary().getTotalPrincipalRepaid()) < 0) {
final String errorMessage = "The transaction amount cannot exceed threshold.";
throw new InvalidLoanStateTransitionException("transaction", "amount.exceeds.threshold", errorMessage);
@@ -0,0 +1,29 @@
/**
* 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.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
public interface CapitalizedIncomeBalanceService {
Money calculateCapitalizedIncome(Loan loan);
Money calculateCapitalizedIncomeAdjustment(Loan loan);
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -35,8 +36,11 @@ import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransact
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LoanBalanceService {
private final CapitalizedIncomeBalanceService capitalizedIncomeBalanceService;
public Money calculateTotalOverpayment(final Loan loan) {
Money totalPaidInRepayments = loan.getTotalPaidInRepayments();
@@ -97,7 +101,10 @@ public class LoanBalanceService {
loan.setTotalRecovered(recoveredAmount.getAmountDefaultedToNullIfZero());
final Money principal = loan.getLoanRepaymentScheduleDetail().getPrincipal();
loan.getSummary().updateSummary(loan.getCurrency(), principal, loan.getRepaymentScheduleInstallments(), loan.getLoanCharges());
final Money capitalizedIncome = capitalizedIncomeBalanceService.calculateCapitalizedIncome(loan);
final Money capitalizedIncomeAdjustment = capitalizedIncomeBalanceService.calculateCapitalizedIncomeAdjustment(loan);
loan.getSummary().updateSummary(loan.getCurrency(), principal, loan.getRepaymentScheduleInstallments(), loan.getLoanCharges(),
capitalizedIncome, capitalizedIncomeAdjustment);
loan.updateLoanOutstandingBalances();
}
}
@@ -173,7 +173,10 @@ public class LoanDownPaymentHandlerServiceImpl implements LoanDownPaymentHandler
if (loan.getLoanProduct().isMultiDisburseLoan()) {
final BigDecimal totalDisbursed = loan.getDisbursedAmount();
final BigDecimal totalPrincipalAdjusted = loan.getSummary().getTotalPrincipalAdjustments();
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted);
final BigDecimal totalCapitalizedIncome = loan.getLoanTransactions(LoanTransaction::isCapitalizedIncome).stream()
.filter(LoanTransaction::isNotReversed).map(LoanTransaction::getPrincipalPortion)
.reduce(BigDecimal.ZERO, BigDecimal::add);
final BigDecimal totalPrincipalCredited = totalDisbursed.add(totalPrincipalAdjusted).add(totalCapitalizedIncome);
if (totalPrincipalCredited.compareTo(loan.getSummary().getTotalPrincipalRepaid()) < 0
&& loan.repaymentScheduleDetail().getPrincipal().minus(totalDisbursed).isGreaterThanZero()) {
final String errorMessage = "The transaction amount cannot exceed threshold.";
@@ -37,6 +37,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
import org.apache.fineract.portfolio.loanaccount.data.PaidInAdvanceData;
import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData;
@@ -50,7 +51,8 @@ public interface LoanReadPlatformService {
LoanAccountData fetchRepaymentScheduleData(LoanAccountData accountData);
LoanScheduleData retrieveRepaymentSchedule(Long loanId, RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData,
Collection<DisbursementData> disbursementData, boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType);
Collection<DisbursementData> disbursementData, Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData,
boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType);
Collection<LoanTransactionData> retrieveLoanTransactions(Long loanId);
@@ -344,6 +344,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
case REAMORTIZE -> handleReAmortization(loanTransaction, ctx);
case REAGE -> handleReAge(loanTransaction, ctx);
case ACCRUAL_ACTIVITY -> calculateAccrualActivity(loanTransaction, ctx);
case CAPITALIZED_INCOME -> handleCapitalizedIncome(loanTransaction, ctx);
// TODO: Cover rest of the transaction types
default -> log.warn("Unhandled transaction processing for transaction type: {}", loanTransaction.getTypeOf());
}
@@ -1199,18 +1200,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
emiCalculator.addDisbursement(model, transactionDate, amortizableAmount);
disbursementTransaction.resetDerivedComponents();
if (amortizableAmount.isGreaterThanZero()) {
model.repaymentPeriods().forEach(rm -> {
LoanRepaymentScheduleInstallment installment = installments.stream().filter(
ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment() && !ri.getDueDate().isBefore(transactionDate))
.findFirst().orElse(null);
if (installment != null) {
installment.updatePrincipal(rm.getDuePrincipal().getAmount());
installment.updateInterestCharged(rm.getDueInterest().getAmount());
installment.updateObligationsMet(currency, transactionDate);
}
});
}
recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, transactionDate, currency);
allocateOverpayment(disbursementTransaction, transactionCtx);
}
@@ -1222,7 +1212,6 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanRepaymentScheduleInstallment> candidateRepaymentInstallments = installments.stream().filter(
i -> i.getDueDate().isAfter(disbursementTransaction.getTransactionDate()) && !i.isDownPayment() && !i.isAdditional())
.toList();
int noCandidateRepaymentInstallments = candidateRepaymentInstallments.size();
LoanProductRelatedDetail loanProductRelatedDetail = disbursementTransaction.getLoan().getLoanRepaymentScheduleDetail();
Integer installmentAmountInMultiplesOf = disbursementTransaction.getLoan().getLoanProduct().getInstallmentAmountInMultiplesOf();
Money downPaymentAmount = Money.zero(currency);
@@ -1240,7 +1229,73 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
Money amortizableAmount = disbursementTransaction.getAmount(currency).minus(downPaymentAmount);
recalculateRepaymentInstallmentsWithoutEMICalculation(disbursementTransaction, amortizableAmount, candidateRepaymentInstallments,
currency, installmentAmountInMultiplesOf);
allocateOverpayment(disbursementTransaction, transactionCtx);
}
private void handleCapitalizedIncome(LoanTransaction capitalizedIncomeTransaction, TransactionCtx transactionCtx) {
if (capitalizedIncomeTransaction.getLoan().isInterestBearing()) {
handleCapitalizedIncomeWithEMICalculator(capitalizedIncomeTransaction, transactionCtx);
} else {
handleCapitalizedIncomeWithoutEMICalculator(capitalizedIncomeTransaction, transactionCtx);
}
}
private void handleCapitalizedIncomeWithEMICalculator(LoanTransaction capitalizedIncomeTransaction, TransactionCtx transactionCtx) {
ProgressiveLoanInterestScheduleModel model;
if (!(transactionCtx instanceof ProgressiveTransactionCtx)
|| (model = ((ProgressiveTransactionCtx) transactionCtx).getModel()) == null) {
throw new IllegalStateException("TransactionCtx has no model");
}
List<LoanRepaymentScheduleInstallment> installments = transactionCtx.getInstallments();
LocalDate transactionDate = capitalizedIncomeTransaction.getTransactionDate();
MonetaryCurrency currency = transactionCtx.getCurrency();
Money amortizableAmount = capitalizedIncomeTransaction.getAmount(currency);
emiCalculator.addCapitalizedIncome(model, transactionDate, amortizableAmount);
recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, transactionDate, currency);
allocateOverpayment(capitalizedIncomeTransaction, transactionCtx);
}
private void handleCapitalizedIncomeWithoutEMICalculator(LoanTransaction capitalizedIncomeTransaction, TransactionCtx transactionCtx) {
MonetaryCurrency currency = transactionCtx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = transactionCtx.getInstallments();
List<LoanRepaymentScheduleInstallment> candidateRepaymentInstallments = installments.stream().filter(
i -> i.getDueDate().isAfter(capitalizedIncomeTransaction.getTransactionDate()) && !i.isDownPayment() && !i.isAdditional())
.toList();
Integer installmentAmountInMultiplesOf = capitalizedIncomeTransaction.getLoan().getLoanProduct()
.getInstallmentAmountInMultiplesOf();
Money amortizableAmount = capitalizedIncomeTransaction.getAmount(currency);
recalculateRepaymentInstallmentsWithoutEMICalculation(capitalizedIncomeTransaction, amortizableAmount,
candidateRepaymentInstallments, currency, installmentAmountInMultiplesOf);
allocateOverpayment(capitalizedIncomeTransaction, transactionCtx);
}
private void recalculateRepaymentPeriodsWithEMICalculation(Money amortizableAmount, ProgressiveLoanInterestScheduleModel model,
List<LoanRepaymentScheduleInstallment> installments, LocalDate transactionDate, MonetaryCurrency currency) {
if (amortizableAmount.isGreaterThanZero()) {
model.repaymentPeriods().forEach(rm -> {
LoanRepaymentScheduleInstallment installment = installments.stream().filter(
ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment() && !ri.getDueDate().isBefore(transactionDate))
.findFirst().orElse(null);
if (installment != null) {
installment.updatePrincipal(rm.getDuePrincipal().getAmount());
installment.updateInterestCharged(rm.getDueInterest().getAmount());
installment.updateObligationsMet(currency, transactionDate);
}
});
}
}
private void recalculateRepaymentInstallmentsWithoutEMICalculation(LoanTransaction loanTransaction, Money amortizableAmount,
List<LoanRepaymentScheduleInstallment> candidateRepaymentInstallments, MonetaryCurrency currency,
Integer installmentAmountInMultiplesOf) {
if (amortizableAmount.isGreaterThanZero()) {
int noCandidateRepaymentInstallments = candidateRepaymentInstallments.size();
Money increasePrincipalBy = amortizableAmount.dividedBy(noCandidateRepaymentInstallments, MoneyHelper.getMathContext());
MoneyHolder moneyHolder = new MoneyHolder(amortizableAmount);
@@ -1252,14 +1307,12 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
i.updatePrincipal(newPrincipal.getAmount());
moneyHolder.setMoneyObject(moneyHolder.getMoneyObject().minus(newPrincipal).plus(previousPrincipal));
i.updateObligationsMet(currency, disbursementTransaction.getTransactionDate());
i.updateObligationsMet(currency, loanTransaction.getTransactionDate());
});
// Hence the rounding, we might need to amend the last installment amount
candidateRepaymentInstallments.get(noCandidateRepaymentInstallments - 1)
.addToPrincipal(disbursementTransaction.getTransactionDate(), moneyHolder.getMoneyObject());
candidateRepaymentInstallments.get(noCandidateRepaymentInstallments - 1).addToPrincipal(loanTransaction.getTransactionDate(),
moneyHolder.getMoneyObject());
}
allocateOverpayment(disbursementTransaction, transactionCtx);
}
private void allocateOverpayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) {
@@ -18,15 +18,29 @@
*/
package org.apache.fineract.portfolio.loanaccount.repository;
import java.math.BigDecimal;
import java.util.List;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
public interface LoanCapitalizedIncomeBalanceRepository
extends JpaRepository<LoanCapitalizedIncomeBalance, Long>, JpaSpecificationExecutor<LoanCapitalizedIncomeBalance> {
String FIND_BALANCE_REPAYMENT_SCHEDULE_DATA = "SELECT new org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData(lcib.loanTransaction.id, lcib.loan.id, lcib.loanTransaction.dateOf, lcib.loanTransaction.reversed, lcib.amount, lcib.unrecognizedAmount, lcib.loanTransaction.feeChargesPortion) FROM LoanCapitalizedIncomeBalance lcib ";
List<LoanCapitalizedIncomeBalance> findAllByLoanId(Long loanId);
LoanCapitalizedIncomeBalance findByLoanIdAndLoanTransactionId(Long loanId, Long transactionId);
@Query(FIND_BALANCE_REPAYMENT_SCHEDULE_DATA + " WHERE lcib.loan.id = :loanId")
List<LoanTransactionRepaymentPeriodData> findRepaymentPeriodDataByLoanId(Long loanId);
@Query("SELECT SUM(lcib.amount) FROM LoanCapitalizedIncomeBalance lcib WHERE lcib.loan.id = :loanId")
BigDecimal calculateCapitalizedIncome(Long loanId);
@Query("SELECT SUM(lcib.amountAdjustment) FROM LoanCapitalizedIncomeBalance lcib WHERE lcib.loan.id = :loanId")
BigDecimal calculateCapitalizedIncomeAdjustment(Long loanId);
}
@@ -0,0 +1,45 @@
/**
* 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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository;
@Slf4j
@RequiredArgsConstructor
public class CapitalizedIncomeBalanceServiceImpl implements CapitalizedIncomeBalanceService {
private final LoanCapitalizedIncomeBalanceRepository capitalizedIncomeBalanceRepository;
@Override
public Money calculateCapitalizedIncome(Loan loan) {
BigDecimal balance = capitalizedIncomeBalanceRepository.calculateCapitalizedIncome(loan.getId());
return Money.of(loan.getCurrency(), balance);
}
@Override
public Money calculateCapitalizedIncomeAdjustment(Loan loan) {
BigDecimal balance = capitalizedIncomeBalanceRepository.calculateCapitalizedIncomeAdjustment(loan.getId());
return Money.of(loan.getCurrency(), balance);
}
}
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
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.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
@@ -54,6 +55,8 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
private final NoteWritePlatformService noteWritePlatformService;
private final ExternalIdFactory externalIdFactory;
private final LoanCapitalizedIncomeBalanceRepository capitalizedIncomeBalanceRepository;
private final ReprocessLoanTransactionsService reprocessLoanTransactionsService;
private final LoanBalanceService loanBalanceService;
@Transactional
@Override
@@ -74,21 +77,24 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
final Money capitalizedIncomeAmount = calculateCapitalizedIncomeAmount(loan, transactionAmount);
final LoanTransaction capitalizedIncomeTransaction = LoanTransaction.capitalizedIncome(loan, capitalizedIncomeAmount, paymentDetail,
transactionDate, txnExternalId);
// TODO: Transaction reverse-replay + loan repayment schedule update (follow-up story)
// Update loan with capitalized income
loan.addLoanTransaction(capitalizedIncomeTransaction);
// Recalculate loan transactions
recalculateLoanTransactions(loan, transactionDate, capitalizedIncomeTransaction);
// Create capitalized income balances
createCapitalizedIncomeBalance(capitalizedIncomeTransaction);
// Save and flush (PK is set)
loanTransactionRepository.saveAndFlush(capitalizedIncomeTransaction);
// Create capitalized income balances
createCapitalizedIncomeBalance(capitalizedIncomeTransaction);
// Update loan counters and save
loan.updateLoanScheduleDependentDerivedFields();
// Update outstanding loan balances
loan.updateLoanOutstandingBalances();
// Update loan summary data
loanBalanceService.updateLoanSummaryDerivedFields(loan);
// Create a note if provided
final String noteText = command.stringValueOfParameterNamed("note");
if (noteText != null && !noteText.isEmpty()) {
noteWritePlatformService.createLoanNote(loanId, noteText);
noteWritePlatformService.createLoanTransactionNote(capitalizedIncomeTransaction.getId(), noteText);
}
// Post journal entries
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
@@ -99,6 +105,14 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
.build();
}
private void recalculateLoanTransactions(Loan loan, LocalDate transactionDate, LoanTransaction capitalizedIncomeTransaction) {
if (loan.isInterestBearingAndInterestRecalculationEnabled() || DateUtils.isBeforeBusinessDate(transactionDate)) {
reprocessLoanTransactionsService.reprocessTransactions(loan);
} else {
reprocessLoanTransactionsService.processLatestTransaction(capitalizedIncomeTransaction, loan);
}
}
@Override
public void resetBalance(final Long loanId) {
capitalizedIncomeBalanceRepository
@@ -24,12 +24,16 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator;
import org.apache.fineract.portfolio.loanaccount.service.CapitalizedIncomeBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.CapitalizedIncomeBalanceServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.CapitalizedIncomePlatformService;
import org.apache.fineract.portfolio.loanaccount.service.CapitalizedIncomeWritePlatformServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanTransactionValidator;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanTransactionValidatorImpl;
import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService;
import org.apache.fineract.portfolio.note.service.NoteWritePlatformService;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -45,10 +49,11 @@ public class ProgressiveLoanAccountConfiguration {
LoanAssembler loanAssembler, LoanTransactionRepository loanTransactionRepository,
PaymentDetailWritePlatformService paymentDetailWritePlatformService, LoanJournalEntryPoster journalEntryPoster,
NoteWritePlatformService noteWritePlatformService, ExternalIdFactory externalIdFactory,
LoanCapitalizedIncomeBalanceRepository capitalizedIncomeBalanceRepository) {
LoanCapitalizedIncomeBalanceRepository capitalizedIncomeBalanceRepository,
ReprocessLoanTransactionsService reprocessLoanTransactionsService, LoanBalanceService loanBalanceService) {
return new CapitalizedIncomeWritePlatformServiceImpl(loanTransactionValidator, loanAssembler, loanTransactionRepository,
paymentDetailWritePlatformService, journalEntryPoster, noteWritePlatformService, externalIdFactory,
capitalizedIncomeBalanceRepository);
capitalizedIncomeBalanceRepository, reprocessLoanTransactionsService, loanBalanceService);
}
@Bean
@@ -59,4 +64,11 @@ public class ProgressiveLoanAccountConfiguration {
return new ProgressiveLoanTransactionValidatorImpl(fromApiJsonHelper, loanTransactionValidator, loanRepositoryWrapper,
loanCapitalizedIncomeBalanceRepository);
}
@Bean
@ConditionalOnMissingBean(CapitalizedIncomeBalanceService.class)
public CapitalizedIncomeBalanceService capitalizedIncomeBalanceService(
LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository) {
return new CapitalizedIncomeBalanceServiceImpl(loanCapitalizedIncomeBalanceRepository);
}
}
@@ -65,6 +65,12 @@ public interface EMICalculator {
*/
void addDisbursement(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate disbursementDueDate, Money disbursedAmount);
/**
* Applies the capitalized income transaction on the interest model. This method recalculates the EMI amounts from
* the action date.
*/
void addCapitalizedIncome(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate transactionDueDate, Money transactionAmount);
/**
* Applies the interest rate change on the interest model. This method recalculates the EMI amounts from the action
* date.
@@ -119,7 +119,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
private void addDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
scheduleModel
.changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), operation.getAmount(),
scheduleModel.zero())
scheduleModel.zero(), scheduleModel.zero())
.ifPresent((repaymentPeriod) -> calculateEMIValueAndRateFactors(
getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, operation.getSubmittedOnDate()), scheduleModel,
operation));
@@ -140,6 +140,24 @@ public final class ProgressiveEMICalculator implements EMICalculator {
return changedRepaymentPeriod.getDueDate();
}
/**
* Add capitalized income to Interest Period
*/
@Override
public void addCapitalizedIncome(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate transactionDueDate,
final Money transactionAmount) {
addCapitalizedIncome(scheduleModel, EmiChangeOperation.capitalizedIncome(transactionDueDate, transactionAmount));
}
private void addCapitalizedIncome(final ProgressiveLoanInterestScheduleModel scheduleModel, final EmiChangeOperation operation) {
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), scheduleModel.zero(),
scheduleModel.zero(), operation.getAmount()).ifPresent((repaymentPeriod) -> {
calculateEMIValueAndRateFactors(
getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, operation.getSubmittedOnDate()), scheduleModel,
operation);
});
}
@Override
public void changeInterestRate(final ProgressiveLoanInterestScheduleModel scheduleModel, final LocalDate newInterestSubmittedOnDate,
final BigDecimal newInterestRate) {
@@ -151,7 +169,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
scheduleModel.addInterestRate(interestRateChangeEffectiveDate, operation.getInterestRate());
scheduleModel
.changeOutstandingBalanceAndUpdateInterestPeriods(interestRateChangeEffectiveDate, scheduleModel.zero(),
scheduleModel.zero())
scheduleModel.zero(), scheduleModel.zero())
.ifPresent(repaymentPeriod -> calculateEMIValueAndRateFactors(
getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, interestRateChangeEffectiveDate), scheduleModel,
operation));
@@ -160,8 +178,8 @@ public final class ProgressiveEMICalculator implements EMICalculator {
@Override
public void addBalanceCorrection(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate balanceCorrectionDate,
Money balanceCorrectionAmount) {
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(balanceCorrectionDate, scheduleModel.zero(), balanceCorrectionAmount)
.ifPresent(repaymentPeriod -> {
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(balanceCorrectionDate, scheduleModel.zero(), balanceCorrectionAmount,
scheduleModel.zero()).ifPresent(repaymentPeriod -> {
calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel);
calculateOutstandingBalance(scheduleModel);
calculateLastUnpaidRepaymentPeriodEMI(scheduleModel);
@@ -237,7 +255,7 @@ public final class ProgressiveEMICalculator implements EMICalculator {
private void addCredit(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate transactionDate, Money creditedPrincipalAmount,
Money creditedInterestAmount) {
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(transactionDate, scheduleModel.zero(), creditedPrincipalAmount)
scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(transactionDate, scheduleModel.zero(), creditedPrincipalAmount, scheduleModel.zero())
.ifPresent(repaymentPeriod -> {
addCreditedAmountsToInterestPeriod(scheduleModel, transactionDate, creditedPrincipalAmount, creditedInterestAmount);
calculateRateFactorForRepaymentPeriod(repaymentPeriod, scheduleModel);
@@ -440,10 +458,13 @@ public final class ProgressiveEMICalculator implements EMICalculator {
.reduce(scheduleModel.zero(), (m1, m2) -> m1.plus(m2, mc)); // 101.48
Money totalDisbursedAmount = scheduleModel.repaymentPeriods().stream()
.flatMap(rp -> rp.getInterestPeriods().stream().map(InterestPeriod::getDisbursementAmount))
.reduce(scheduleModel.zero(), (m1, m2) -> m1.plus(m2, mc)) // 100
.plus(scheduleModel.getTotalCreditedPrincipal(), mc); //
.reduce(scheduleModel.zero(), (m1, m2) -> m1.plus(m2, mc)); // 100
Money totalCapitalizedIncome = scheduleModel.repaymentPeriods().stream()
.flatMap(rp -> rp.getInterestPeriods().stream().map(InterestPeriod::getCapitalizedIncomePrincipal))
.reduce(scheduleModel.zero(), (m1, m2) -> m1.plus(m2, mc)); // 100
Money diff = totalDisbursedAmount.plus(totalDueInterest, mc).minus(totalEMI, mc);
Money diff = totalDisbursedAmount.plus(totalCapitalizedIncome, mc).plus(scheduleModel.getTotalCreditedPrincipal(), mc)
.plus(totalDueInterest, mc).minus(totalEMI, mc);
Optional<RepaymentPeriod> findLastUnpaidRepaymentPeriod = scheduleModel.repaymentPeriods().stream().filter(rp -> !rp.isFullyPaid())
.reduce((first, second) -> second);
findLastUnpaidRepaymentPeriod.ifPresent(repaymentPeriod -> {
@@ -809,7 +830,9 @@ public final class ProgressiveEMICalculator implements EMICalculator {
return;
}
final ProgressiveLoanInterestScheduleModel scheduleModelCopy = scheduleModel.copyWithoutPaidAmounts();
addDisbursement(scheduleModelCopy, operation.withZeroAmount());
addCapitalizedIncome(scheduleModelCopy, operation.withZeroAmount());
final LocalDate firstDueDate = repaymentPeriods.get(0).getDueDate();
scheduleModel.copyPeriodsFrom(firstDueDate, scheduleModelCopy.repaymentPeriods(), (newRepaymentPeriod, actualRepaymentPeriod) -> {
@@ -31,7 +31,9 @@ public class EmiChangeOperation {
public enum Action { //
DISBURSEMENT, //
INTEREST_RATE_CHANGE //
INTEREST_RATE_CHANGE, //
CAPITALIZED_INCOME, //
;
}
private final Action action;
@@ -48,8 +50,12 @@ public class EmiChangeOperation {
return new EmiChangeOperation(EmiChangeOperation.Action.INTEREST_RATE_CHANGE, newInterestSubmittedOnDate, null, newInterestRate);
}
public static EmiChangeOperation capitalizedIncome(final LocalDate transactionDueDate, final Money transactionAmount) {
return new EmiChangeOperation(Action.CAPITALIZED_INCOME, transactionDueDate, transactionAmount, null);
}
public EmiChangeOperation withZeroAmount() {
if (action == Action.DISBURSEMENT) {
if (action == Action.DISBURSEMENT || action == Action.CAPITALIZED_INCOME) {
return new EmiChangeOperation(action, submittedOnDate, amount.zero(), null);
}
return null;
@@ -62,6 +62,8 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
private Money balanceCorrectionAmount;
private Money outstandingLoanBalance;
private Money capitalizedIncomePrincipal;
@JsonExclude
private final MathContext mc;
private final boolean isPaused;
@@ -70,38 +72,38 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
return new InterestPeriod(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(),
interestPeriod.getRateFactor(), interestPeriod.getRateFactorTillPeriodDueDate(), interestPeriod.getCreditedPrincipal(),
interestPeriod.getCreditedInterest(), interestPeriod.getDisbursementAmount(), interestPeriod.getBalanceCorrectionAmount(),
interestPeriod.getOutstandingLoanBalance(), mc, interestPeriod.isPaused());
interestPeriod.getOutstandingLoanBalance(), interestPeriod.getCapitalizedIncomePrincipal(), mc, interestPeriod.isPaused());
}
public static InterestPeriod empty(@NotNull RepaymentPeriod repaymentPeriod, MathContext mc) {
return new InterestPeriod(repaymentPeriod, null, null, null, null, null, null, null, null, null, mc, false);
return new InterestPeriod(repaymentPeriod, null, null, null, null, null, null, null, null, null,null, mc, false);
}
public static InterestPeriod copy(@NotNull RepaymentPeriod repaymentPeriod, @NotNull InterestPeriod interestPeriod) {
return new InterestPeriod(repaymentPeriod, interestPeriod.getFromDate(), interestPeriod.getDueDate(),
interestPeriod.getRateFactor(), interestPeriod.getRateFactorTillPeriodDueDate(), interestPeriod.getCreditedPrincipal(),
interestPeriod.getCreditedInterest(), interestPeriod.getDisbursementAmount(), interestPeriod.getBalanceCorrectionAmount(),
interestPeriod.getOutstandingLoanBalance(), interestPeriod.getMc(), interestPeriod.isPaused());
interestPeriod.getOutstandingLoanBalance(), interestPeriod.getCapitalizedIncomePrincipal(), interestPeriod.getMc(), interestPeriod.isPaused());
}
public static InterestPeriod withEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate,
LocalDate dueDate) {
final Money zero = repaymentPeriod.getEmi().zero();
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero,
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero, zero,
zero.getMc(), false);
}
public static InterestPeriod withEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate, LocalDate dueDate,
boolean isPaused) {
final Money zero = repaymentPeriod.getEmi().zero();
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero,
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero, zero,
zero.getMc(), isPaused);
}
public static InterestPeriod withPausedAndEmptyAmounts(@NotNull RepaymentPeriod repaymentPeriod, @NotNull LocalDate fromDate,
LocalDate dueDate) {
final Money zero = repaymentPeriod.getEmi().zero();
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero,
return new InterestPeriod(repaymentPeriod, fromDate, dueDate, BigDecimal.ZERO, BigDecimal.ZERO, zero, zero, zero, zero, zero, zero,
zero.getMc(), true);
}
@@ -126,6 +128,10 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
this.creditedInterest = MathUtil.plus(this.creditedInterest, creditedInterest, mc);
}
public void addCapitalizedIncomePrincipalAmount(final Money capitalizedIncomePrincipal) {
this.capitalizedIncomePrincipal = MathUtil.plus(this.capitalizedIncomePrincipal, capitalizedIncomePrincipal, mc);
}
public BigDecimal getCalculatedDueInterest() {
if (isPaused) {
return creditedInterest.getAmount();
@@ -156,6 +162,7 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
InterestPeriod previousInterestPeriod = previousRepaymentPeriod.get().getLastInterestPeriod();
this.outstandingLoanBalance = MathUtil.negativeToZero(previousInterestPeriod.getOutstandingLoanBalance()//
.plus(previousInterestPeriod.getDisbursementAmount(), mc)//
.plus(previousInterestPeriod.getCapitalizedIncomePrincipal(), mc)//
.plus(previousInterestPeriod.getBalanceCorrectionAmount(), mc)//
.minus(previousRepaymentPeriod.get().getDuePrincipal(), mc)//
.plus(previousRepaymentPeriod.get().getPaidPrincipal(), mc), mc);//
@@ -165,6 +172,7 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
InterestPeriod previousInterestPeriod = getRepaymentPeriod().getInterestPeriods().get(index - 1);
this.outstandingLoanBalance = MathUtil.negativeToZero(previousInterestPeriod.getOutstandingLoanBalance() //
.plus(previousInterestPeriod.getBalanceCorrectionAmount(), mc) //
.plus(previousInterestPeriod.getCapitalizedIncomePrincipal(), mc) //
.plus(previousInterestPeriod.getDisbursementAmount(), mc)); //
}
}
@@ -173,7 +181,7 @@ public class InterestPeriod implements Comparable<InterestPeriod> {
* Include principal like amounts (all disbursement amount + credited principal)
*/
public Money getCreditedAmounts() {
return getDisbursementAmount().plus(getCreditedPrincipal(), mc);
return getDisbursementAmount().plus(getCreditedPrincipal(), mc).plus(getCapitalizedIncomePrincipal(), mc);
}
public boolean isFirstInterestPeriod() {
@@ -175,9 +175,10 @@ public class ProgressiveLoanInterestScheduleModel {
}
public Optional<RepaymentPeriod> changeOutstandingBalanceAndUpdateInterestPeriods(final LocalDate balanceChangeDate,
final Money disbursedAmount, final Money correctionAmount) {
final Money disbursedAmount, final Money correctionAmount, final Money capitalizedIncomePrincipal) {
return findRepaymentPeriodForBalanceChange(balanceChangeDate).stream()//
.peek(updateInterestPeriodOnRepaymentPeriod(balanceChangeDate, disbursedAmount, correctionAmount))//
.peek(updateInterestPeriodOnRepaymentPeriod(balanceChangeDate, disbursedAmount, correctionAmount,
capitalizedIncomePrincipal))//
.findFirst();//
}
@@ -214,7 +215,7 @@ public class ProgressiveLoanInterestScheduleModel {
}
private Consumer<RepaymentPeriod> updateInterestPeriodOnRepaymentPeriod(final LocalDate balanceChangeDate, final Money disbursedAmount,
final Money correctionAmount) {
final Money correctionAmount, final Money capitalizedIncomePrincipal) {
return repaymentPeriod -> {
final boolean isChangeOnMaturityDate = isLastRepaymentPeriod(repaymentPeriod)
&& balanceChangeDate.isEqual(repaymentPeriod.getDueDate());
@@ -222,9 +223,10 @@ public class ProgressiveLoanInterestScheduleModel {
isChangeOnMaturityDate);
if (interestPeriodOptional.isPresent()) {
interestPeriodOptional.get().addDisbursementAmount(disbursedAmount);
interestPeriodOptional.get().addCapitalizedIncomePrincipalAmount(capitalizedIncomePrincipal);
interestPeriodOptional.get().addBalanceCorrectionAmount(correctionAmount);
} else {
insertInterestPeriod(repaymentPeriod, balanceChangeDate, disbursedAmount, correctionAmount);
insertInterestPeriod(repaymentPeriod, balanceChangeDate, disbursedAmount, correctionAmount, capitalizedIncomePrincipal);
}
};
}
@@ -246,7 +248,7 @@ public class ProgressiveLoanInterestScheduleModel {
}
void insertInterestPeriod(final RepaymentPeriod repaymentPeriod, final LocalDate balanceChangeDate, final Money disbursedAmount,
final Money correctionAmount) {
final Money correctionAmount, Money capitalizedIncomePrincipal) {
final InterestPeriod previousInterestPeriod = findPreviousInterestPeriod(repaymentPeriod, balanceChangeDate);
final LocalDate originalDueDate = previousInterestPeriod.getDueDate();
final LocalDate newDueDate = calculateNewDueDate(previousInterestPeriod, balanceChangeDate);
@@ -254,6 +256,7 @@ public class ProgressiveLoanInterestScheduleModel {
previousInterestPeriod.setDueDate(newDueDate);
previousInterestPeriod.addDisbursementAmount(disbursedAmount);
previousInterestPeriod.addCapitalizedIncomePrincipalAmount(capitalizedIncomePrincipal);
previousInterestPeriod.addBalanceCorrectionAmount(correctionAmount);
final InterestPeriod interestPeriod = InterestPeriod.withEmptyAmounts(repaymentPeriod, newDueDate, originalDueDate, isPaused);
@@ -225,6 +225,17 @@ public final class RepaymentPeriod {
.reduce(getZero(mc), (value, previous) -> value.plus(previous, mc)), mc); //
}
/**
* Sum of capitalized income principals
*
* @return
*/
public Money getCapitalizedIncomePrincipal() {
return MathUtil.negativeToZero(interestPeriods.stream() //
.map(InterestPeriod::getCapitalizedIncomePrincipal) //
.reduce(getZero(mc), (value, previous) -> value.plus(previous, mc)), mc); //
}
/**
* Gives back due principal + credited principal or paid principal
*
@@ -278,6 +289,7 @@ public final class RepaymentPeriod {
InterestPeriod lastInterestPeriod = getInterestPeriods().get(getInterestPeriods().size() - 1);
Money calculatedOutStandingLoanBalance = lastInterestPeriod.getOutstandingLoanBalance() //
.plus(lastInterestPeriod.getBalanceCorrectionAmount(), mc) //
.plus(lastInterestPeriod.getCapitalizedIncomePrincipal(), mc) //
.plus(lastInterestPeriod.getDisbursementAmount(), mc) //
.minus(getDuePrincipal(), mc)//
.plus(getPaidPrincipal(), mc);//
@@ -305,7 +317,10 @@ public final class RepaymentPeriod {
Money totalDisbursedAmount = getInterestPeriods().stream() //
.map(InterestPeriod::getDisbursementAmount) //
.reduce(getZero(mc), (m1, m2) -> m1.plus(m2, mc)); //
return initialBalance.add(totalDisbursedAmount, mc);
Money totalCapitalizedIncomeAmount = getInterestPeriods().stream() //
.map(InterestPeriod::getCapitalizedIncomePrincipal) //
.reduce(getZero(mc), (m1, m2) -> m1.plus(m2, mc)); //
return initialBalance.add(totalDisbursedAmount, mc).add(totalCapitalizedIncomeAmount, mc);
}
private Money getZero(MathContext mc) {
@@ -464,6 +464,7 @@ public class LoanImportHandler implements ImportHandler {
JsonObject loanRepaymentJsonob = gsonBuilder.create().toJsonTree(loanRepayments.get(rowIndex)).getAsJsonObject();
loanRepaymentJsonob.remove("manuallyReversed");
loanRepaymentJsonob.remove("numberOfRepayments");
loanRepaymentJsonob.remove("reversed");
String payload = loanRepaymentJsonob.toString();
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.loanRepaymentTransaction(result.getLoanId()) //
@@ -139,6 +139,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeTyp
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.apache.fineract.portfolio.loanaccount.domain.LoanSummaryBalancesRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTermVariationType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTemplateTypeRequiredException;
import org.apache.fineract.portfolio.loanaccount.exception.NotSupportedLoanTemplateTypeException;
@@ -150,6 +151,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanSchedul
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.service.LoanScheduleCalculationPlatformService;
import org.apache.fineract.portfolio.loanaccount.loanschedule.service.LoanScheduleHistoryReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanTermVariationsRepository;
import org.apache.fineract.portfolio.loanaccount.service.GLIMAccountInfoReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargeReadPlatformService;
@@ -297,6 +299,7 @@ public class LoansApiResource {
private final ClientReadPlatformService clientReadPlatformService;
private final LoanTermVariationsRepository loanTermVariationsRepository;
private final LoanSummaryProviderDelegate loanSummaryProviderDelegate;
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
/*
* This template API is used for loan approval, ideally this should be invoked on loan that are pending for
@@ -498,11 +501,13 @@ public class LoansApiResource {
loanBasicDetails.getPageItems().forEach(i -> {
if (i.getSummary() != null) {
Collection<DisbursementData> disbursementData = this.loanReadPlatformService.retrieveLoanDisbursementDetails(i.getId());
List<LoanTransactionRepaymentPeriodData> capitalizedIncomeData = this.loanCapitalizedIncomeBalanceRepository
.findRepaymentPeriodDataByLoanId(i.getId());
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData = new RepaymentScheduleRelatedLoanData(
i.getTimeline().getExpectedDisbursementDate(), i.getTimeline().getActualDisbursementDate(), i.getCurrency(),
i.getPrincipal(), i.getInArrearsTolerance(), i.getFeeChargesAtDisbursementCharged());
final LoanScheduleData repaymentSchedule = this.loanReadPlatformService.retrieveRepaymentSchedule(i.getId(),
repaymentScheduleRelatedData, disbursementData, i.isInterestRecalculationEnabled(),
repaymentScheduleRelatedData, disbursementData, capitalizedIncomeData, i.isInterestRecalculationEnabled(),
LoanScheduleType.fromEnumOptionData(i.getLoanScheduleType()));
LoanSummaryDataProvider loanSummaryDataProvider = loanSummaryProviderDelegate
.resolveLoanSummaryDataProvider(i.getTransactionProcessingStrategyCode());
@@ -981,13 +986,15 @@ public class LoansApiResource {
if (associationParameters.contains(DataTableApiConstant.repaymentScheduleAssociateParamName)) {
mandatoryResponseParameters.add(DataTableApiConstant.repaymentScheduleAssociateParamName);
List<LoanTransactionRepaymentPeriodData> capitalizedIncomeData = this.loanCapitalizedIncomeBalanceRepository
.findRepaymentPeriodDataByLoanId(resolvedLoanId);
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData = new RepaymentScheduleRelatedLoanData(
loanBasicDetails.getTimeline().getExpectedDisbursementDate(),
loanBasicDetails.getTimeline().getActualDisbursementDate(), loanBasicDetails.getCurrency(),
loanBasicDetails.getPrincipal(), loanBasicDetails.getInArrearsTolerance(),
loanBasicDetails.getFeeChargesAtDisbursementCharged());
repaymentSchedule = this.loanReadPlatformService.retrieveRepaymentSchedule(resolvedLoanId, repaymentScheduleRelatedData,
disbursementData, loanBasicDetails.isInterestRecalculationEnabled(),
disbursementData, capitalizedIncomeData, loanBasicDetails.isInterestRecalculationEnabled(),
LoanScheduleType.fromEnumOptionData(loanBasicDetails.getLoanScheduleType()));
if (associationParameters.contains(DataTableApiConstant.futureScheduleAssociateParamName)
@@ -517,7 +517,11 @@ final class LoansApiResourceSwagger {
public GetLoansLoanIdCurrency currency;
@Schema(example = "1000000.000000")
public BigDecimal totalPrincipal;
@Schema(example = "1000000.000000")
public BigDecimal principalDisbursed;
@Schema(example = "1000000.000000")
public BigDecimal totalCapitalizedIncome;
@Schema(example = "0.000000")
public BigDecimal principalPaid;
@Schema(example = "0.00")
@@ -97,7 +97,9 @@ public abstract class CommonLoanSummaryDataProvider implements LoanSummaryDataPr
}
return LoanSummaryData.builder().currency(defaultSummaryData.getCurrency())
.principalDisbursed(defaultSummaryData.getPrincipalDisbursed())
.principalDisbursed(defaultSummaryData.getPrincipalDisbursed()).totalPrincipal(defaultSummaryData.getTotalPrincipal())
.totalCapitalizedIncome(defaultSummaryData.getTotalCapitalizedIncome())
.totalCapitalizedIncomeAdjustment(defaultSummaryData.getTotalCapitalizedIncomeAdjustment())
.principalAdjustments(defaultSummaryData.getPrincipalAdjustments()).principalPaid(defaultSummaryData.getPrincipalPaid())
.principalWrittenOff(defaultSummaryData.getPrincipalWrittenOff())
.principalOutstanding(defaultSummaryData.getPrincipalOutstanding())
@@ -21,10 +21,12 @@ package org.apache.fineract.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
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.MathUtil;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAdjustTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
@@ -51,9 +53,10 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
@Transactional
public void processCapitalizedIncomeAmortizationOnLoanClosure(@NotNull final Loan loan) {
final LocalDate transactionDate = getFinalCapitalizedIncomeAmortizationTransactionDate(loan);
final LoanTransaction amortizationTransaction = createCapitalizedIncomeAmortizationTransaction(loan, transactionDate, false);
businessEventNotifierService
.notifyPostBusinessEvent(new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(amortizationTransaction));
final Optional<LoanTransaction> amortizationTransaction = createCapitalizedIncomeAmortizationTransaction(loan, transactionDate,
false);
amortizationTransaction.ifPresent(loanTransaction -> businessEventNotifierService
.notifyPostBusinessEvent(new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(loanTransaction)));
}
@Override
@@ -67,13 +70,16 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
transactionDate = DateUtils.getBusinessLocalDate();
}
final LoanTransaction amortizationTransaction = createCapitalizedIncomeAmortizationTransaction(loan, transactionDate, true);
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
businessEventNotifierService
.notifyPostBusinessEvent(new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(amortizationTransaction));
final Optional<LoanTransaction> amortizationTransaction = createCapitalizedIncomeAmortizationTransaction(loan, transactionDate,
true);
if (amortizationTransaction.isPresent()) {
journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
businessEventNotifierService.notifyPostBusinessEvent(
new LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent(amortizationTransaction.get()));
}
}
private LoanTransaction createCapitalizedIncomeAmortizationTransaction(final Loan loan, final LocalDate transactionDate,
private Optional<LoanTransaction> createCapitalizedIncomeAmortizationTransaction(final Loan loan, final LocalDate transactionDate,
final boolean isChargeOff) {
ExternalId externalId = ExternalId.empty();
BigDecimal totalUnrecognizedAmount = BigDecimal.ZERO;
@@ -92,13 +98,17 @@ public class LoanCapitalizedIncomeAmortizationProcessingServiceImpl implements L
balance.setUnrecognizedAmount(BigDecimal.ZERO);
}
if (MathUtil.isLessThanOrEqualZero(totalUnrecognizedAmount)) {
return Optional.empty();
}
final LoanTransaction amortizationTransaction = LoanTransaction.capitalizedIncomeAmortization(loan, loan.getOffice(),
transactionDate, totalUnrecognizedAmount, externalId);
loan.addLoanTransaction(amortizationTransaction);
loanTransactionRepository.saveAndFlush(amortizationTransaction);
return amortizationTransaction;
return Optional.of(amortizationTransaction);
}
@Override
@@ -98,6 +98,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanApprovalData;
import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByData;
import org.apache.fineract.portfolio.loanaccount.data.LoanInterestRecalculationData;
import org.apache.fineract.portfolio.loanaccount.data.LoanRepaymentScheduleInstallmentData;
import org.apache.fineract.portfolio.loanaccount.data.LoanSchedulePeriodDataWrapper;
import org.apache.fineract.portfolio.loanaccount.data.LoanStatusEnumData;
import org.apache.fineract.portfolio.loanaccount.data.LoanSummaryData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
@@ -118,6 +119,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.apache.fineract.portfolio.loanaccount.domain.LoanSubStatus;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
@@ -249,8 +251,10 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
accountData.getFeeChargesAtDisbursementCharged());
final Collection<DisbursementData> disbursementData = retrieveLoanDisbursementDetails(accountData.getId());
List<LoanTransactionRepaymentPeriodData> capitalizedIncomeData = loanCapitalizedIncomeBalanceRepository
.findRepaymentPeriodDataByLoanId(accountData.getId());
final LoanScheduleData repaymentSchedule = retrieveRepaymentSchedule(accountData.getId(), repaymentScheduleRelatedData,
disbursementData, accountData.isInterestRecalculationEnabled(),
disbursementData, capitalizedIncomeData, accountData.isInterestRecalculationEnabled(),
LoanScheduleType.fromEnumOptionData(accountData.getLoanScheduleType()));
accountData.setRepaymentSchedule(repaymentSchedule);
return accountData;
@@ -259,13 +263,15 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
@Override
public LoanScheduleData retrieveRepaymentSchedule(final Long loanId,
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection<DisbursementData> disbursementData,
boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) {
Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData, boolean isInterestRecalculationEnabled,
LoanScheduleType loanScheduleType) {
try {
this.context.authenticatedUser();
final LoanScheduleResultSetExtractor fullResultsetExtractor = new LoanScheduleResultSetExtractor(
repaymentScheduleRelatedLoanData, disbursementData, isInterestRecalculationEnabled, loanScheduleType);
repaymentScheduleRelatedLoanData, disbursementData, capitalizedIncomeData, isInterestRecalculationEnabled,
loanScheduleType);
final String sql = "select " + fullResultsetExtractor.schema() + " where ls.loan_id = ? order by ls.loan_id, ls.installment";
return this.jdbcTemplate.query(sql, fullResultsetExtractor, loanId); // NOSONAR
@@ -749,7 +755,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
+ " l.expected_disbursedon_date as expectedDisbursementDate, l.disbursedon_date as actualDisbursementDate, dbu.username as disbursedByUsername, dbu.firstname as disbursedByFirstname, dbu.lastname as disbursedByLastname,"
+ " l.closedon_date as closedOnDate, cbu.username as closedByUsername, cbu.firstname as closedByFirstname, cbu.lastname as closedByLastname, l.writtenoffon_date as writtenOffOnDate, "
+ " l.expected_firstrepaymenton_date as expectedFirstRepaymentOnDate, l.interest_calculated_from_date as interestChargedFromDate, l.maturedon_date as actualMaturityDate, l.expected_maturedon_date as expectedMaturityDate, "
+ " l.principal_amount_proposed as proposedPrincipal, l.principal_amount as principal, l.approved_principal as approvedPrincipal, l.net_disbursal_amount as netDisbursalAmount, l.arrearstolerance_amount as inArrearsTolerance, l.number_of_repayments as numberOfRepayments, l.repay_every as repaymentEvery,"
+ " l.principal_amount_proposed as proposedPrincipal, l.principal_amount as principal, l.total_principal_derived as totalPrincipal, l.approved_principal as approvedPrincipal, l.net_disbursal_amount as netDisbursalAmount, l.arrearstolerance_amount as inArrearsTolerance, l.number_of_repayments as numberOfRepayments, l.repay_every as repaymentEvery,"
+ " l.grace_on_principal_periods as graceOnPrincipalPayment, l.recurring_moratorium_principal_periods as recurringMoratoriumOnPrincipalPeriods, l.grace_on_interest_periods as graceOnInterestPayment, l.grace_interest_free_periods as graceOnInterestCharged,l.grace_on_arrears_ageing as graceOnArrearsAgeing,"
+ " l.nominal_interest_rate_per_period as interestRatePerPeriod, l.annual_nominal_interest_rate as annualInterestRate, "
+ " l.repayment_period_frequency_enum as repaymentFrequencyType, l.interest_period_frequency_enum as interestRateFrequencyType, "
@@ -764,7 +770,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
+ sqlGenerator.escape("name")
+ " as currencyName, rc.display_symbol as currencyDisplaySymbol, rc.internationalized_name_code as currencyNameCode, "
+ " l.loan_officer_id as loanOfficerId, s.display_name as loanOfficerName, "
+ " l.principal_disbursed_derived as principalDisbursed, l.principal_repaid_derived as principalPaid,"
+ " l.principal_disbursed_derived as principalDisbursed, l.capitalized_income_derived as totalCapitalizedIncome, l.capitalized_income_adjustment_derived as totalCapitalizedIncomeAdjustment, l.principal_repaid_derived as principalPaid,"
+ " l.principal_adjustments_derived as principalAdjustments, l.principal_writtenoff_derived as principalWrittenOff,"
+ " l.fee_adjustments_derived as feeAdjustments, l.penalty_adjustments_derived as penaltyAdjustments,"
+ " l.principal_outstanding_derived as principalOutstanding, l.interest_charged_derived as interestCharged,"
@@ -1023,6 +1029,10 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
// loan summary
final BigDecimal principalDisbursed = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalDisbursed");
final BigDecimal totalPrincipal = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalPrincipal");
final BigDecimal totalCapitalizedIncome = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalCapitalizedIncome");
final BigDecimal totalCapitalizedIncomeAdjustment = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs,
"totalCapitalizedIncomeAdjustment");
final BigDecimal principalAdjustments = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalAdjustments");
final BigDecimal principalPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalPaid");
final BigDecimal principalWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalWrittenOff");
@@ -1066,12 +1076,14 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
inArrears = (overdueSinceDate != null);
loanSummary = LoanSummaryData.builder().currency(currencyData).principalDisbursed(principalDisbursed)
.principalAdjustments(principalAdjustments).principalPaid(principalPaid).principalWrittenOff(principalWrittenOff)
.principalOutstanding(principalOutstanding).principalOverdue(principalOverdue).interestCharged(interestCharged)
.interestPaid(interestPaid).interestWaived(interestWaived).interestWrittenOff(interestWrittenOff)
.interestOutstanding(interestOutstanding).interestOverdue(interestOverdue).feeChargesCharged(feeChargesCharged)
.feeAdjustments(feeAdjustments).feeChargesDueAtDisbursementCharged(feeChargesDueAtDisbursementCharged)
.feeChargesPaid(feeChargesPaid).feeChargesWaived(feeChargesWaived).feeChargesWrittenOff(feeChargesWrittenOff)
.totalPrincipal(totalPrincipal).totalCapitalizedIncome(totalCapitalizedIncome)
.totalCapitalizedIncomeAdjustment(totalCapitalizedIncomeAdjustment).principalAdjustments(principalAdjustments)
.principalPaid(principalPaid).principalWrittenOff(principalWrittenOff).principalOutstanding(principalOutstanding)
.principalOverdue(principalOverdue).interestCharged(interestCharged).interestPaid(interestPaid)
.interestWaived(interestWaived).interestWrittenOff(interestWrittenOff).interestOutstanding(interestOutstanding)
.interestOverdue(interestOverdue).feeChargesCharged(feeChargesCharged).feeAdjustments(feeAdjustments)
.feeChargesDueAtDisbursementCharged(feeChargesDueAtDisbursementCharged).feeChargesPaid(feeChargesPaid)
.feeChargesWaived(feeChargesWaived).feeChargesWrittenOff(feeChargesWrittenOff)
.feeChargesOutstanding(feeChargesOutstanding).feeChargesOverdue(feeChargesOverdue)
.penaltyChargesCharged(penaltyChargesCharged).penaltyAdjustments(penaltyAdjustments)
.penaltyChargesPaid(penaltyChargesPaid).penaltyChargesWaived(penaltyChargesWaived)
@@ -1271,15 +1283,18 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
private final DisbursementData disbursement;
private final BigDecimal totalFeeChargesDueAtDisbursement;
private final Collection<DisbursementData> disbursementData;
private final Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData;
private final LoanScheduleType loanScheduleType;
private LocalDate lastDueDate;
private BigDecimal outstandingLoanPrincipalBalance;
private boolean excludePastUnDisbursed;
LoanScheduleResultSetExtractor(final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData,
Collection<DisbursementData> disbursementData, boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) {
Collection<DisbursementData> disbursementData, Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData,
boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) {
this.currency = repaymentScheduleRelatedLoanData.getCurrency();
this.disbursement = repaymentScheduleRelatedLoanData.disbursementData();
this.capitalizedIncomeData = capitalizedIncomeData;
this.totalFeeChargesDueAtDisbursement = repaymentScheduleRelatedLoanData.getTotalFeeChargesAtDisbursement();
this.lastDueDate = this.disbursement.disbursementDate();
this.outstandingLoanPrincipalBalance = this.disbursement.getPrincipal();
@@ -1359,10 +1374,15 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
final LocalDate dueDate = JdbcSupport.getLocalDate(rs, "dueDate");
final LocalDate obligationsMetOnDate = JdbcSupport.getLocalDate(rs, "obligationsMetOnDate");
final boolean complete = rs.getBoolean("complete");
BigDecimal disbursedAmount = BigDecimal.ZERO;
disbursedAmount = processDisbursementData(loanScheduleType, disbursementData, fromDate, dueDate, disbursementPeriodIds,
disbursementChargeAmount, waivedChargeAmount, periods);
List<LoanSchedulePeriodDataWrapper> combinedDataList = new ArrayList<>();
combinedDataList.addAll(
collectEligibleDisbursementData(loanScheduleType, disbursementData, fromDate, dueDate, disbursementPeriodIds));
combinedDataList.addAll(collectEligibleCapitalizedIncomeData(fromDate, dueDate, disbursementPeriodIds));
combinedDataList.sort(this::sortPeriodDataHolders);
fillLoanSchedulePeriodData(periods, combinedDataList, disbursementChargeAmount, waivedChargeAmount);
BigDecimal disbursedAmount = calculateDisbursedAmount(combinedDataList);
// Add the Charge back or Credits to the initial amount to avoid negative balance
final BigDecimal principalCredits = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalCredits");
@@ -1473,26 +1493,82 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
totalPaidLate.getAmount(), totalOutstanding.getAmount(), totalCredits.getAmount());
}
private BigDecimal processDisbursementData(LoanScheduleType loanScheduleType, Collection<DisbursementData> disbursementData,
LocalDate fromDate, LocalDate dueDate, Set<Long> disbursementPeriodIds, BigDecimal disbursementChargeAmount,
BigDecimal waivedChargeAmount, List<LoanSchedulePeriodData> periods) {
BigDecimal disbursedAmount = BigDecimal.ZERO;
private List<LoanSchedulePeriodDataWrapper> collectEligibleDisbursementData(LoanScheduleType loanScheduleType,
Collection<DisbursementData> disbursementData, LocalDate fromDate, LocalDate dueDate, Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> disbursementDataList = new ArrayList<>();
// Collect eligible disbursement data
for (final DisbursementData data : disbursementData) {
boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
if (((fromDate.equals(this.disbursement.disbursementDate()) && data.disbursementDate().equals(fromDate))
boolean isEligible = ((fromDate.equals(this.disbursement.disbursementDate()) && data.disbursementDate().equals(fromDate))
|| (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate))
|| canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed))
&& !disbursementPeriodIds.contains(data.getId())) {
disbursedAmount = disbursedAmount.add(data.getPrincipal());
LoanSchedulePeriodData periodData = createLoanSchedulePeriodData(data, disbursementChargeAmount, waivedChargeAmount);
periods.add(periodData);
this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.add(periodData.getPrincipalDisbursed());
&& !disbursementPeriodIds.contains(data.getId());
if (isEligible) {
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
disbursementPeriodIds.add(data.getId());
}
}
return disbursementDataList;
}
private List<LoanSchedulePeriodDataWrapper> collectEligibleCapitalizedIncomeData(LocalDate fromDate, LocalDate dueDate,
Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> capitalizedIncomeDataList = new ArrayList<>();
// Collect eligible capitalized income data
for (LoanTransactionRepaymentPeriodData data : capitalizedIncomeData) {
boolean isEligible = canAddCapitalizedIncomeData(data, fromDate, dueDate)
&& !disbursementPeriodIds.contains(data.getTransactionId());
if (isEligible) {
capitalizedIncomeDataList.add(new LoanSchedulePeriodDataWrapper(data, data.getDate(), false));
disbursementPeriodIds.add(data.getTransactionId());
}
}
return capitalizedIncomeDataList;
}
private void fillLoanSchedulePeriodData(List<LoanSchedulePeriodData> periods, List<LoanSchedulePeriodDataWrapper> combinedDataList,
BigDecimal disbursementChargeAmount, BigDecimal waivedChargeAmount) {
// Process all collected data in chronological order
for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) {
LoanSchedulePeriodData periodData;
if (dataItem.isDisbursement()) {
// Process disbursement data
DisbursementData data = (DisbursementData) dataItem.getData();
periodData = createLoanSchedulePeriodData(data, disbursementChargeAmount, waivedChargeAmount);
} else {
// Process capitalized income data
LoanTransactionRepaymentPeriodData data = (LoanTransactionRepaymentPeriodData) dataItem.getData();
periodData = createLoanSchedulePeriodData(data);
}
// Common processing for both data types
periods.add(periodData);
this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.add(periodData.getPrincipalDisbursed());
}
}
private BigDecimal calculateDisbursedAmount(List<LoanSchedulePeriodDataWrapper> combinedDataList) {
BigDecimal disbursedAmount = BigDecimal.ZERO;
for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) {
if (dataItem.isDisbursement()) {
DisbursementData data = (DisbursementData) dataItem.getData();
disbursedAmount = disbursedAmount.add(data.getPrincipal());
}
}
return disbursedAmount;
}
private int sortPeriodDataHolders(LoanSchedulePeriodDataWrapper item1, LoanSchedulePeriodDataWrapper item2) {
int dateComparison = item1.getDate().compareTo(item2.getDate());
if (dateComparison == 0 && item1.isDisbursement() != item2.isDisbursement()) {
// If dates are equal, prioritize disbursement data
return item1.isDisbursement() ? -1 : 1;
}
return dateComparison;
}
private LoanSchedulePeriodData createLoanSchedulePeriodData(final DisbursementData data, BigDecimal disbursementChargeAmount,
BigDecimal waivedChargeAmount) {
BigDecimal chargeAmount = data.getChargeAmount() == null ? disbursementChargeAmount
@@ -1501,11 +1577,20 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
data.isDisbursed());
}
private LoanSchedulePeriodData createLoanSchedulePeriodData(final LoanTransactionRepaymentPeriodData data) {
BigDecimal feeCharges = Objects.isNull(data.getFeeChargesPortion()) ? BigDecimal.ZERO : data.getFeeChargesPortion();
return LoanSchedulePeriodData.disbursementOnlyPeriod(data.getDate(), data.getAmount(), feeCharges, !data.isReversed());
}
private boolean canAddDisbursementData(DisbursementData data, boolean isDueForDisbursement, boolean excludePastUnDisbursed) {
return (!excludePastUnDisbursed || data.isDisbursed() || !DateUtils.isBeforeBusinessDate(data.disbursementDate()))
&& isDueForDisbursement;
}
private boolean canAddCapitalizedIncomeData(LoanTransactionRepaymentPeriodData data, LocalDate fromDate, LocalDate dueDate) {
return !data.isReversed() && DateUtils.isDateInRangeFromInclusiveToExclusive(fromDate, dueDate, data.getDate());
}
}
private static final class LoanTransactionsMapper implements RowMapper<LoanTransactionData> {
@@ -2168,7 +2253,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
final LoanTransactionEnumData transactionType = LoanEnumerations.transactionType(LoanTransactionType.REPAYMENT);
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final BigDecimal outstandingLoanBalance = loanRepaymentScheduleInstallment.getPrincipalOutstanding(currency).getAmount();
final Boolean isReversed = false;
final Boolean isManuallyReversed = false;
final Money outStandingAmount = loanRepaymentScheduleInstallment.getTotalOutstanding(currency);
@@ -2178,7 +2263,8 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
loanRepaymentScheduleInstallment.getInterestOutstanding(currency).getAmount(),
loanRepaymentScheduleInstallment.getFeeChargesOutstanding(currency).getAmount(),
loanRepaymentScheduleInstallment.getPenaltyChargesOutstanding(currency).getAmount(), null, unrecognizedIncomePortion,
paymentTypeOptions, ExternalId.empty(), null, null, outstandingLoanBalance, isReversed, loanId, loan.getExternalId());
paymentTypeOptions, ExternalId.empty(), null, null, outstandingLoanBalance, isManuallyReversed, loanId,
loan.getExternalId());
}
private static final class CurrencyMapper implements RowMapper<CurrencyData> {
@@ -64,6 +64,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.LoanDownPaymentHandlerService;
import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster;
import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService;
import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService;
import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
@@ -93,6 +94,7 @@ public class LoanAdjustmentServiceImpl implements LoanAdjustmentService {
private final LoanChargeValidator loanChargeValidator;
private final LoanJournalEntryPoster journalEntryPoster;
private final LoanBalanceService loanBalanceService;
private final ReprocessLoanTransactionsService reprocessLoanTransactionsService;
@Override
public CommandProcessingResult adjustLoanTransaction(Loan loan, LoanTransaction transactionToAdjust, LoanAdjustmentParameter parameter,
@@ -245,8 +247,9 @@ public class LoanAdjustmentServiceImpl implements LoanAdjustmentService {
transactionForAdjustment.getTransactionDate());
if (!transactionForAdjustment.isAccrualRelated() && transactionForAdjustment.isNotRepaymentLikeType()
&& transactionForAdjustment.isNotWaiver() && transactionForAdjustment.isNotCreditBalanceRefund()) {
final String errorMessage = "Only (non-reversed) transactions of type repayment, waiver, accrual or credit balance refund can be adjusted.";
&& transactionForAdjustment.isNotWaiver() && transactionForAdjustment.isNotCreditBalanceRefund()
&& !transactionForAdjustment.isCapitalizedIncome()) {
final String errorMessage = "Only (non-reversed) transactions of type repayment, waiver, capitalized income, accrual or credit balance refund can be adjusted.";
throw new InvalidLoanTransactionTypeException("transaction",
"adjustment.is.only.allowed.to.repayment.or.waiver.or.creditbalancerefund.transactions", errorMessage);
}
@@ -285,6 +288,10 @@ public class LoanAdjustmentServiceImpl implements LoanAdjustmentService {
loanDownPaymentHandlerService.handleRepaymentOrRecoveryOrWaiverTransaction(loan, newTransactionDetail, transactionForAdjustment,
scheduleGeneratorDTO);
}
if (transactionForAdjustment.getTypeOf().equals(LoanTransactionType.CAPITALIZED_INCOME)) {
reprocessLoanTransactionsService.reprocessTransactions(loan);
}
}
private Loan saveAndFlushLoanWithDataIntegrityViolationChecks(final Loan loan) {
@@ -204,9 +204,13 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf
}
@Override
public void createLoanNote(final Long loanId, final String note) {
final Loan loan = this.loanRepository.findOneWithNotFoundDetection(loanId);
final Note newNote = Note.loanNote(loan, note);
public void createLoanTransactionNote(final Long loanTransactionId, final String note) {
final LoanTransaction loanTransaction = this.loanTransactionRepository.findById(loanTransactionId)
.orElseThrow(() -> new LoanTransactionNotFoundException(loanTransactionId));
final Loan loan = loanTransaction.getLoan();
final Note newNote = Note.loanTransactionNote(loan, loanTransaction, note);
this.noteRepository.save(newNote);
}
@@ -196,4 +196,5 @@
<include file="parts/0175_add_fk_acc_product_mapping.xml" relativeToChangelogFile="true" />
<include file="parts/0176_add_capitalized_income_amortization_transaction.xml" relativeToChangelogFile="true" />
<include file="parts/0177_acc_journal_entry_index.xml" relativeToChangelogFile="true" />
<include file="parts/0178_add_principal_from_capitalized_income_to_loan_summary.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.3.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_loan">
<column defaultValueNumeric="0.000000" name="capitalized_income_derived" type="DECIMAL(19, 6)">
<constraints nullable="false"/>
</column>
<column defaultValueNumeric="0.000000" name="capitalized_income_adjustment_derived" type="DECIMAL(19, 6)">
<constraints nullable="false"/>
</column>
<column defaultValueNumeric="0.000000" name="total_principal_derived" type="DECIMAL(19, 6)">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -78,7 +78,7 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
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.02, "Accrual", "02 January 2024"), //
transaction(0.03, "Accrual", "02 January 2024"), //
transaction(0.55, "Capitalized Income Amortization", "02 January 2024") //
);
@@ -89,8 +89,8 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest {
journalEntry(50, deferredIncomeLiabilityAccount, "CREDIT"), //
journalEntry(0.55, deferredIncomeLiabilityAccount, "DEBIT"), //
journalEntry(0.55, feeIncomeAccount, "CREDIT"), //
journalEntry(0.02, interestReceivableAccount, "DEBIT"), //
journalEntry(0.02, interestIncomeAccount, "CREDIT"), //
journalEntry(0.03, interestReceivableAccount, "DEBIT"), //
journalEntry(0.03, interestIncomeAccount, "CREDIT"), //
journalEntry(0.55, deferredIncomeLiabilityAccount, "DEBIT"), //
journalEntry(0.55, feeIncomeAccount, "CREDIT") //
);
@@ -19,7 +19,9 @@
package org.apache.fineract.integrationtests;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansResponse;
@@ -248,4 +250,37 @@ public class LoanSummaryTest extends BaseLoanIntegrationTest {
});
}
@Test
public void testCapitalizedIncomeExistsInRepaymentScheduleAndModifiesPrincipal() {
runAt("01 March 2023", () -> {
final PostLoanProductsResponse loanProductsResponse = loanProductHelper
.createLoanProduct(create4IProgressiveWithCapitalizedIncome());
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applyLP2ProgressiveLoanRequest(clientId,
loanProductsResponse.getResourceId(), "01 March 2023", 10000.00, 12.00, 4, null));
loanId = postLoansResponse.getLoanId();
loanTransactionHelper.approveLoan(loanId, approveLoanRequest(10000.00, "01 March 2023"));
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 March 2023");
});
runAt("02 March 2023", () -> {
loanTransactionHelper.addCapitalizedIncome(loanId, "02 March 2023", 100.00);
});
BigDecimal thousand = BigDecimal.valueOf(1000.0);
BigDecimal hundred = BigDecimal.valueOf(100.0);
BigDecimal thousandOneHundred = BigDecimal.valueOf(1100.0);
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
Assertions.assertEquals(thousand, loanDetails.getPrincipal().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(thousand, loanDetails.getSummary().getPrincipalDisbursed().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(hundred, loanDetails.getSummary().getTotalCapitalizedIncome().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(thousandOneHundred, loanDetails.getSummary().getTotalPrincipal().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(thousandOneHundred, loanDetails.getSummary().getPrincipalOutstanding().setScale(1, RoundingMode.HALF_UP));
List<GetLoansLoanIdRepaymentPeriod> periods = loanDetails.getRepaymentSchedule().getPeriods();
Assertions.assertEquals(6, periods.size());
Assertions.assertEquals(thousand, periods.get(0).getPrincipalLoanBalanceOutstanding().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(hundred, periods.get(1).getPrincipalLoanBalanceOutstanding().setScale(1, RoundingMode.HALF_UP));
}
}